"""Store a dashboard upload as an import snapshot, start a build from it, and list what was received.

Two steps on purpose: the upload stores the files and *shows what was received* (kinds, what was left out, what to fix)
before anything runs or costs credits; the start then builds from the stored snapshot through the same path an AI
connection uses (``AgentCodegenFacade.start``): adoption plan, markup check, prompt history, dispatch.
"""

from __future__ import annotations

import asyncio
import json
import re
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Literal, Optional
from uuid import UUID

import structlog
from pydantic import BaseModel, Field
from pydantic import ValidationError as SchemaValidationError

from src.core.exceptions import NotFoundError, ValidationError
from src.domain.codegen.import_snapshot import (
    ImportSnapshotError,
    read_snapshot_files,
    snapshots_dir,
    write_snapshot,
)
from src.domain.codegen.source_import import describe_import, extract_sources
from src.domain.adopt.contract import AdoptAttachment, ExternalEndpoint
from src.domain.adopt.import_scan import scan_import

logger = structlog.get_logger()

PROMPT_SOURCE = "dashboard_import"
_IMPORT_ID = re.compile(r"[0-9a-f]{32}")
_LIST_LIMIT = 20

DEFAULT_PROMPTS = {
    "migrate": "Turn the uploaded source files into a working app in this project. Keep the design, copy and "
    "behavior exactly as they are.",
    "replace": "Replace this project's UI with the uploaded source files. Keep the design exactly as it is.",
    "merge": "Merge the uploaded source files into this project.",
    "add": "Add the uploaded source files to this project.",
}


class SourceImportStartBody(BaseModel):
    prompt: Optional[str] = Field(default=None, max_length=8000)
    source_adoption: Literal["migrate", "replace", "merge", "add"]
    deployment_intent: Literal["modify_only", "modify_and_deploy"] = "modify_and_deploy"
    adopt_confirmation: dict[str, Any] | None = None


def _base(settings, project) -> Path:
    return snapshots_dir(settings, project.workspace_id, project.id)


def load_manifest(settings, project, import_id: str) -> dict[str, Any]:
    if not _IMPORT_ID.fullmatch(import_id or ""):
        raise NotFoundError("Import not found")
    path = _base(settings, project) / import_id / "manifest.json"
    try:
        return json.loads(path.read_text("utf-8"))
    except (OSError, ValueError) as exc:
        raise NotFoundError("Import not found") from exc


async def store_upload(settings, project, user, uploads: list[tuple[str, bytes]]) -> dict[str, Any]:
    """Read the upload, snapshot the text files, and describe what was received."""
    extracted = await asyncio.to_thread(extract_sources, uploads)
    manifest = await asyncio.to_thread(
        write_snapshot,
        _base(settings, project),
        extracted.files,
        source={"kind": "dashboard", "user_id": str(user.id)},
    )
    logger.info(
        "source_import_stored",
        project_id=str(project.id),
        import_id=manifest["import_id"],
        files=len(manifest["files"]),
        skipped=len(extracted.skipped),
    )
    summary = describe_import(manifest, extracted.skipped)
    summary["adoption_analysis"] = scan_import({path: text for path, text in extracted.files})
    return summary


def _adopt_attachment(confirmation: dict[str, Any] | None) -> AdoptAttachment | None:
    """Turn the dashboard's explicit choice into the Core-owned adopt contract.

    This intentionally produces a non-active attachment. Endpoint verification,
    route allowlisting, and projection contract tests are later gates owned by Core.
    """
    if not confirmation:
        return None
    decision = str(confirmation.get("decision") or "").strip()
    if decision not in {"worker_projection", "runtime", "backend_repo", "unavailable"}:
        raise ValidationError("Choose how existing backend routes should be handled.")
    if decision == "runtime":
        origin = str(confirmation.get("runtime_origin") or "").strip()
        if not origin:
            raise ValidationError("An existing backend origin is required.")
        endpoint = ExternalEndpoint(
            id="import-runtime",
            browser_prefix=str(confirmation.get("api_prefix") or "/api"),
            origin=origin,
            upstream_prefix="",
        )
        return AdoptAttachment(
            backend_source="external_runtime_only",
            backend_write_policy="frozen",
            backend_repository="none",
            p2hs_hosted_runtime="external_endpoints",
            external_endpoints=[endpoint],
            attachment_status="awaiting_origin_or_projection",
        )
    if decision == "backend_repo":
        repository = str(confirmation.get("backend_repository") or "").strip()
        if not repository:
            raise ValidationError("A backend repository URL is required.")
        return AdoptAttachment(
            backend_source="separate",
            backend_write_policy="read_only",
            backend_repository=repository,
            p2hs_hosted_runtime="worker_projection",
            attachment_status="awaiting_origin_or_projection",
        )
    return AdoptAttachment(
        backend_source="co_located",
        backend_write_policy="frozen",
        backend_repository="none",
        p2hs_hosted_runtime="worker_projection",
        attachment_status="awaiting_origin_or_projection",
    )


async def start_from_import(db, settings, project, user, import_id: str, body: SourceImportStartBody):
    """Start a build from a stored import. Returns the run (the caller commits and enqueues it)."""
    from src.domain.agent_access.operations import StartBuild
    from src.domain.codegen.agent_facade import AgentCodegenFacade

    manifest = load_manifest(settings, project, import_id)
    try:
        contents = await asyncio.to_thread(read_snapshot_files, _base(settings, project), manifest)
    except (ImportSnapshotError, OSError, ValueError, KeyError) as exc:
        raise ValidationError(
            "The uploaded files are no longer available or changed on disk. Upload them again."
        ) from exc
    attachments = [SimpleNamespace(path=p, content=data.decode("utf-8")) for p, data in sorted(contents.items())]
    analysis = scan_import({attachment.path: attachment.content for attachment in attachments})
    if analysis["has_external_api"] and body.source_adoption != "migrate" and not body.adopt_confirmation:
        raise ValidationError("Confirm how the existing backend routes should be handled before starting.")
    adopt_attachment = _adopt_attachment(body.adopt_confirmation)
    prompt = (body.prompt or "").strip() or DEFAULT_PROMPTS[body.source_adoption]
    try:
        request = StartBuild(
            request_key=f"dash-{import_id}",
            project_id=project.id,
            prompt=prompt,
            attachments=[{"path": a.path, "content": a.content} for a in attachments],
            source_adoption=body.source_adoption,
            deployment_intent=body.deployment_intent,
            adopt_attachment=adopt_attachment.model_dump(mode="json") if adopt_attachment else None,
        )
    except SchemaValidationError as exc:
        first = exc.errors()[0] if exc.errors() else {}
        raise ValidationError(str(first.get("msg") or "The import is not valid").removeprefix("Value error, ")) from exc
    request.ensure_ui_source()  # a "migrate" without any page is refused with what to send
    actor = SimpleNamespace(id=None, created_by=user.id)  # a person, not an AI connection
    return await AgentCodegenFacade(db).start(
        actor, request, prompt_source=PROMPT_SOURCE, attachments=attachments, import_snapshot=manifest
    )


def _run_link(run) -> dict[str, Any]:
    return {"run_id": str(run.id), "status": run.status,
            "created_at": run.created_at.isoformat() if getattr(run, "created_at", None) else None}


async def list_imports(db, settings, project) -> list[dict[str, Any]]:
    """What was received for this project (from the dashboard and from AI connections), newest first."""
    from src.domain.codegen.repository import CodegenRunRepository

    base = _base(settings, project)

    def read_all() -> list[dict[str, Any]]:
        if not base.is_dir():
            return []
        found = []
        for entry in base.iterdir():
            manifest_path = entry / "manifest.json"
            if _IMPORT_ID.fullmatch(entry.name) and manifest_path.is_file():
                try:
                    found.append((manifest_path.stat().st_mtime, json.loads(manifest_path.read_text("utf-8"))))
                except (OSError, ValueError):
                    continue
        found.sort(key=lambda item: item[0], reverse=True)
        return [manifest for _, manifest in found[:_LIST_LIMIT]]

    manifests = await asyncio.to_thread(read_all)
    runs_by_import: dict[str, list[dict[str, Any]]] = {}
    for run in await CodegenRunRepository(db).list_for_project(project.id):
        snapshot = (((getattr(run, "plan", None) or {}).get("context") or {}).get("import_snapshot")) or {}
        if snapshot.get("import_id"):
            runs_by_import.setdefault(str(snapshot["import_id"]), []).append(_run_link(run))
    return [{**describe_import(m), "runs": runs_by_import.get(str(m.get("import_id")), [])} for m in manifests]
