"""Codegen domain - FastAPI router."""
from __future__ import annotations
import json
import os
import tempfile
from typing import Optional
from uuid import UUID

from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
from sqlalchemy.ext.asyncio import AsyncSession

from src.core.database import async_session_factory, get_db
from src.core.exceptions import ValidationError
from src.core.dependencies import require_active_billing_for_project, require_project_codegen_admin
from src.domain.auth.models import User
from src.domain.auth.router import get_current_user
from src.domain.codegen.repository import CodegenRunRepository
from src.domain.codegen.schemas import (
    CapabilityApprovalResponse,
    CloudCutoverBody,
    CodegenActionsCompleteBody,
    CodegenActionsProgressBody,
    CodegenCreditEstimateResponse,
    CodegenRunCreate,
    CodegenRunResponse,
    CodegenRunSummary,
    ResolveCapabilitiesBody,
    SupplyUserInputsBody,
)
from src.domain.codegen.service import CodegenService
from src.domain.codegen.source_import_service import SourceImportStartBody
from src.domain.devices.dependencies import require_run_grant_or_actions_token
from src.domain.devices.bundle_service import DeviceBundleService
from src.domain.manifest.repository import ManifestRunRepository
from src.domain.manifest.service import ManifestService
from src.domain.projects.repository import ProjectRepository, ProjectSettingsRepository
from src.domain.instructions.repository import InstructionRepository
from src.domain.prompt_media.repository import PromptMediaRepository

router = APIRouter(prefix="/api/projects/{project}/codegen", tags=["codegen"])
internal_router = APIRouter(prefix="/api/internal/codegen", tags=["internal-codegen"])


class _ProjectInfoFacadeForCodegen:
    def __init__(self, db: AsyncSession):
        self._project_repo = ProjectRepository(db)
        self._instruction_repo = InstructionRepository(db)

    async def get_project_type(self, project_id: UUID) -> Optional[str]:
        p = await self._project_repo.get_by_id(project_id)
        return p.type if p else None

    async def get_project_instruction_content(self, project_id: UUID) -> str:
        return await self._instruction_repo.get_current_content(project_id)

    async def get_project_repo_full_name(self, project_id: UUID) -> Optional[str]:
        p = await self._project_repo.get_with_repo(project_id)
        return p.repo.repo_full_name if p and hasattr(p, "repo") and p.repo else None


async def get_codegen_service(db: AsyncSession = Depends(get_db)) -> CodegenService:
    from src.domain.projects.repository import ProjectEnvVarRepository
    return CodegenService(
        codegen_repo=CodegenRunRepository(db),
        project_facade=_ProjectInfoFacadeForCodegen(db),
        project_settings_repo=ProjectSettingsRepository(db),
        env_var_repo=ProjectEnvVarRepository(db),
        manifest_service=ManifestService(db=db, run_repo=ManifestRunRepository(db)),
    )


def _bundle_service() -> DeviceBundleService:
    return DeviceBundleService(db_factory=async_session_factory)


def _delete_temp_file(path: str) -> None:
    if os.path.exists(path):
        os.unlink(path)


@router.post("/estimate", response_model=CodegenCreditEstimateResponse)
async def estimate_codegen_run_credits(
    body: CodegenRunCreate,
    project: UUID = Depends(require_active_billing_for_project),
    _: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
) -> CodegenCreditEstimateResponse:
    """Give the builder an explainable credit range before it creates a run."""
    from src.domain.codegen.credit_estimator import estimate_codegen_credits

    attachments = await PromptMediaRepository(db).list_by_ids_for_project(
        body.attachment_ids or [], project
    )
    if len(attachments) != len(set(body.attachment_ids or [])):
        raise ValidationError("One or more prompt attachments do not belong to this project")
    estimate = estimate_codegen_credits(
        prompt="\n".join(filter(None, [body.user_prompt, body.voice_transcript])),
        attachment_sizes=[asset.byte_size for asset in attachments],
        attachment_count=len(body.attachment_ids or []),
    )
    return CodegenCreditEstimateResponse(**estimate.__dict__)


@router.post("/runs", response_model=CodegenRunResponse)
async def create_codegen_run(
    body: CodegenRunCreate,
    project: UUID = Depends(require_active_billing_for_project),
    selected_route: Optional[str] = Query(None),
    viewport: Optional[str] = Query(None),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
    db: AsyncSession = Depends(get_db),
):
    from src.domain.codegen.credit_estimator import estimate_codegen_credits

    attachments = await PromptMediaRepository(db).list_by_ids_for_project(
        body.attachment_ids or [], project
    )
    if len(attachments) != len(set(body.attachment_ids or [])):
        raise ValidationError("One or more prompt attachments do not belong to this project")
    credit_estimate = estimate_codegen_credits(
        prompt="\n".join(filter(None, [body.user_prompt, body.voice_transcript])),
        attachment_sizes=[asset.byte_size for asset in attachments],
        attachment_count=len(body.attachment_ids or []),
    )
    execution_target = body.execution_target
    device_id = None
    if execution_target == "local":
        from src.domain.devices.local_execution import LocalExecutionPolicy
        from src.domain.devices.repository import DeviceRepository
        from src.domain.workspaces.repository import MembershipRepository, WorkspaceSettingsRepository

        project_row = await ProjectRepository(db).get_by_id(project)
        if not project_row:
            from src.core.exceptions import NotFoundError
            raise NotFoundError("Project not found")
        policy = LocalExecutionPolicy(
            devices=DeviceRepository(db),
            memberships=MembershipRepository(db),
            settings=WorkspaceSettingsRepository(db),
        )
        device = await policy.resolve_device(
            workspace_id=project_row.workspace_id,
            user_id=user.id,
        )
        device_id = device.id
    run = await service.create_run(
        project_id=project,
        user_prompt=body.user_prompt,
        prompt_id=body.prompt_id,
        created_by=user.id,
        selected_route=selected_route,
        viewport=viewport,
        run_mode=body.run_mode,
        selected_elements=body.selected_elements,
        attachment_ids=body.attachment_ids,
        voice_transcript=body.voice_transcript,
        credit_estimate=credit_estimate.__dict__,
        execution_target=execution_target,
        device_id=device_id,
    )
    from src.workers.enqueue import enqueue_codegen_run
    await enqueue_codegen_run(str(run.id))
    return CodegenRunResponse.from_run(run)


@router.post("/runs/{run}/execute", response_model=CodegenRunResponse)
async def execute_plan_run(
    project: UUID = Depends(require_active_billing_for_project),
    run: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
):
    """Approve a plan_ready run; same row is re-queued as agent mode."""
    same_run = await service.execute_plan(
        plan_run_id=run,
        project_id=project,
        created_by=user.id,
    )
    from src.workers.enqueue import enqueue_codegen_run
    await enqueue_codegen_run(str(same_run.id))
    return CodegenRunResponse.from_run(same_run)


@router.post("/runs/{run}/cancel", response_model=CodegenRunResponse)
async def cancel_codegen_run(
    project: UUID = Depends(require_active_billing_for_project),
    run: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
):
    """Cancel a queued, plan_ready, or running codegen run."""
    canceled = await service.cancel_run(run_id=run, project_id=project)
    return CodegenRunResponse.from_run(canceled)


@router.post("/runs/{run}/supply-user-inputs", response_model=CodegenRunResponse)
async def supply_user_inputs_for_codegen_run(
    body: SupplyUserInputsBody,
    project: UUID = Depends(require_active_billing_for_project),
    run: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
):
    """Submit manifest values from ``pending_user_inputs``; stores them and enqueues a follow-up codegen run."""
    new_run = await service.supply_user_inputs(
        run_id=run,
        project_id=project,
        values=body.values,
        created_by=user.id,
    )
    from src.workers.enqueue import enqueue_codegen_run, enqueue_env_sync

    await enqueue_codegen_run(str(new_run.id))
    await enqueue_env_sync(str(project))
    return CodegenRunResponse.from_run(new_run)


@router.post("/runs/{run}/retry", response_model=CodegenRunResponse)
async def retry_codegen_run(
    project: UUID = Depends(require_active_billing_for_project),
    run: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
):
    """Create a new queued run from a failed, canceled, or blocked run (same prompt/plan payload)."""
    new_run = await service.retry_run(run_id=run, project_id=project, created_by=user.id)
    from src.workers.enqueue import enqueue_codegen_run
    await enqueue_codegen_run(str(new_run.id))
    return CodegenRunResponse.from_run(new_run)


@router.get("/capability-approvals", response_model=list[CapabilityApprovalResponse])
async def list_capability_approvals(
    project: UUID = Depends(require_active_billing_for_project),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Security-sensitive capabilities the coding agent asked for, with the owner's decisions."""
    from src.domain.codegen.capability_approval import SqlApprovalStore

    return [CapabilityApprovalResponse.model_validate(r) for r in await SqlApprovalStore(db).list(project)]


async def _decide_capability(project: UUID, approval: UUID, approve: bool, user: User, db: AsyncSession):
    from src.domain.codegen.capability_approval import SqlApprovalStore, decide

    row = await decide(SqlApprovalStore(db), project, approval, approve, user.id)
    await db.commit()
    return CapabilityApprovalResponse.model_validate(row)


@router.post("/capability-approvals/{approval}/approve", response_model=CapabilityApprovalResponse)
async def approve_capability(
    approval: UUID = Path(...),
    project: UUID = Depends(require_project_codegen_admin),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Approve one held capability. Workspace admins only: never an AI connection or a plain editor."""
    return await _decide_capability(project, approval, True, user, db)


@router.post("/capability-approvals/{approval}/reject", response_model=CapabilityApprovalResponse)
async def reject_capability(
    approval: UUID = Path(...),
    project: UUID = Depends(require_project_codegen_admin),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Reject one held capability; the next run is sent back to build without it."""
    return await _decide_capability(project, approval, False, user, db)


async def _project_for_source_import(project: UUID, user: User, db: AsyncSession):
    """The project, after checking the person may run builds in its workspace (viewers may not import source)."""
    from src.core.exceptions import ForbiddenError, NotFoundError
    from src.domain.workspaces.repository import MembershipRepository

    row = await ProjectRepository(db).get_by_id(project)
    if not row:
        raise NotFoundError("Project not found")
    if not await MembershipRepository(db).has_permission(row.workspace_id, user.id, "codegen.run"):
        raise ForbiddenError("You need permission to run builds to import source files")
    return row


@router.post("/source-imports")
async def upload_source_import(
    files: list[UploadFile] = File(...),
    project: UUID = Depends(require_active_billing_for_project),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Store uploaded source files (or a .zip) and say what was received. Nothing runs and no credits are used."""
    from src.config import get_settings
    from src.domain.codegen.source_import import MAX_UPLOAD_BYTES
    from src.domain.codegen.source_import_service import store_upload

    row = await _project_for_source_import(project, user, db)
    uploads: list[tuple[str, bytes]] = []
    total = 0
    for upload in files:
        data = await upload.read()
        total += len(data)
        if total > MAX_UPLOAD_BYTES:
            raise ValidationError(f"The upload is larger than {MAX_UPLOAD_BYTES // (1024 * 1024)} MB.")
        uploads.append((upload.filename or "", data))
    return await store_upload(get_settings(), row, user, uploads)


@router.get("/source-imports")
async def list_source_imports(
    project: UUID = Depends(require_active_billing_for_project),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Source received for this project, from the dashboard and from AI connections, with the builds that used it."""
    from src.config import get_settings
    from src.domain.codegen.source_import_service import list_imports

    row = await ProjectRepository(db).get_by_id(project)
    if not row:
        from src.core.exceptions import NotFoundError

        raise NotFoundError("Project not found")
    return await list_imports(db, get_settings(), row)


@router.post("/source-imports/{import_id}/start")
async def start_build_from_source_import(
    body: SourceImportStartBody,
    import_id: str = Path(...),
    project: UUID = Depends(require_active_billing_for_project),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Start a build from an uploaded import, through the same path an AI connection uses."""
    from src.config import get_settings
    from src.domain.codegen.source_import_service import start_from_import
    from src.workers.enqueue import enqueue_codegen_run

    row = await _project_for_source_import(project, user, db)
    run = await start_from_import(db, get_settings(), row, user, import_id, body)
    await db.commit()
    await enqueue_codegen_run(str(run.id))
    return {"run_id": str(run.id), "status": run.status, "import_id": import_id}


@router.get("/runs/{run}", response_model=CodegenRunResponse)
async def get_codegen_run(
    project: UUID = Depends(require_active_billing_for_project),
    run: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    r = await CodegenRunRepository(db).get_by_id(run)
    if not r or r.project_id != project:
        from src.core.exceptions import NotFoundError
        raise NotFoundError("Run not found")
    return CodegenRunResponse.from_run(r)


@router.get("/runs", response_model=list[CodegenRunSummary])
async def list_codegen_runs(
    project: UUID = Depends(require_active_billing_for_project),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(get_codegen_service),
):
    runs = await service.list_runs(project)
    return [CodegenRunSummary.from_run(r) for r in runs]


@internal_router.post("/actions/{run}/cloud-cutover")
async def import_legacy_users_from_executor(
    body: CloudCutoverBody,
    run: UUID = Path(...),
    _: None = Depends(require_run_grant_or_actions_token),
    db: AsyncSession = Depends(get_db),
):
    """Import leftover local app users into Cloud auth and ensure project auth exists."""
    from src.domain.codegen.legacy_cloud_cutover import (
        discovered_users_from_payload,
        ensure_cloud_auth_for_project,
        import_legacy_users_for_project,
    )

    current = await CodegenRunRepository(db).get_by_id(run)
    if not current:
        from src.core.exceptions import NotFoundError
        raise NotFoundError("Run not found")
    try:
        await ensure_cloud_auth_for_project(db, current.project_id)
    except Exception:
        pass
    imported = await import_legacy_users_for_project(
        db,
        current.project_id,
        discovered_users_from_payload([u.model_dump() for u in body.users]),
    )
    await db.commit()
    return {
        "status": "ok",
        "imported": [item.__dict__ for item in imported],
    }


@internal_router.post("/actions/{run}/provision")
async def provision_github_actions_resources(
    body: ResolveCapabilitiesBody,
    run: UUID = Path(...),
    _: None = Depends(require_run_grant_or_actions_token),
    db: AsyncSession = Depends(get_db),
):
    from src.domain.codegen.provision_gate import request_provisioning
    return await request_provisioning(
        CodegenRunRepository(db), run, body.provision, body.pack_installs, body.pack_compositions
    )


@internal_router.post("/actions/{run}/complete")
async def complete_github_actions_codegen_run(
    body: CodegenActionsCompleteBody,
    run: UUID = Path(...),
    _: None = Depends(require_run_grant_or_actions_token),
    service: CodegenService = Depends(get_codegen_service),
):
    """Accept completion from a repo-local executor (GitHub Actions or a paired desktop
    device — see ``require_run_grant_or_actions_token``) and resume Core finalization.

    The executor only executes code. Core owns the run row, final revision creation,
    usage accounting, and preview enqueue.
    """
    return await service.report_run_complete(run, body)


@internal_router.get("/actions/{run}/source-bundle")
async def download_local_source_bundle(
    run: UUID = Path(...),
    _: None = Depends(require_run_grant_or_actions_token),
):
    svc = _bundle_service()
    bundle_path, filename = await svc.build_source_bundle(run)
    return FileResponse(
        bundle_path,
        media_type="application/zip",
        filename=filename,
        background=BackgroundTask(_delete_temp_file, bundle_path),
    )


@internal_router.post("/actions/{run}/bundle-complete")
async def complete_local_bundle_codegen_run(
    run: UUID = Path(...),
    metadata_json: str = Form(...),
    bundle: UploadFile = File(...),
    _: None = Depends(require_run_grant_or_actions_token),
    service: CodegenService = Depends(get_codegen_service),
):
    try:
        payload = json.loads(metadata_json)
    except json.JSONDecodeError as exc:
        raise ValidationError("metadata_json must be valid JSON") from exc
    body = CodegenActionsCompleteBody.model_validate(payload)
    if body.status not in ("completed", "completed_with_l3"):
        return await service.report_run_complete(run, body)

    fd, temp_path = tempfile.mkstemp(prefix=f"p2hs-result-{run}-", suffix=".zip")
    os.close(fd)
    try:
        with open(temp_path, "wb") as fh:
            fh.write(await bundle.read())
        bundle_result = await _bundle_service().apply_result_bundle(
            run_id=run,
            result_bundle_path=temp_path,
            summary=body.summary,
        )
        completed = CodegenActionsCompleteBody(
            status=body.status,
            provider=body.provider,
            commit_sha=bundle_result["commit_sha"],
            branch=bundle_result["branch"],
            summary=body.summary or bundle_result["summary"],
            transcript=body.transcript,
            files_changed=body.files_changed,
            files_changed_detail=body.files_changed_detail,
            tokens_used=body.tokens_used,
            pending_user_inputs=body.pending_user_inputs,
            pending_provision=bundle_result.get("pending_provision"),
            pending_email_templates=bundle_result.get("pending_email_templates"),
            error_message=body.error_message,
            l3_candidate_ids=body.l3_candidate_ids,
            l3_candidates=body.l3_candidates,
        )
        return await service.report_run_complete(run, completed)
    except Exception as exc:
        failed = CodegenActionsCompleteBody(
            status="failed",
            provider=body.provider,
            summary=body.summary,
            transcript=body.transcript,
            files_changed=body.files_changed,
            files_changed_detail=body.files_changed_detail,
            tokens_used=body.tokens_used,
            pending_user_inputs=body.pending_user_inputs,
            error_message=str(exc)[:4000],
        )
        await service.report_run_complete(run, failed)
        raise
    finally:
        if os.path.exists(temp_path):
            os.unlink(temp_path)


@internal_router.post("/actions/{run}/progress")
async def progress_github_actions_codegen_run(
    body: CodegenActionsProgressBody,
    run: UUID = Path(...),
    _: None = Depends(require_run_grant_or_actions_token),
    service: CodegenService = Depends(get_codegen_service),
):
    """Append repo-local executor output and publish it to project WebSocket clients.

    Shared by GitHub Actions and a paired desktop device — see ``require_run_grant_or_actions_token``.
    """
    return await service.report_run_progress(run, body)
