"""Project-rooted builder API - canonical frontend contract.

All builder-facing resources are scoped under /api/projects/{project_id}/...
"""
from __future__ import annotations
import hashlib
import json
import math
import os
from typing import Any, Optional
from uuid import UUID, uuid4

import structlog
from fastapi import APIRouter, Depends, File, Form, Header, Path, Query, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, model_validator
from redis import asyncio as aioredis
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

logger = structlog.get_logger()

from src.config import get_settings
from src.core.database import get_db
from src.core.dependencies import require_active_billing_for_project_id
from src.core.exceptions import ConflictError, NotFoundError, RateLimitError, ValidationError
from src.core.ssrf import fetch_url_bytes
from src.dependencies import get_redis_client
from src.domain.auth.models import User
from src.domain.auth.router import get_current_user
from src.domain.builder.chat_handler import ChatRequest, ChatResponse, handle_project_chat
from src.domain.builder.repository import ChangedFileRepository, TimelineEventRepository
from src.domain.builder.schemas import WorkspaceResponse
from src.domain.builder.service import _TERMINAL_RUN_STATUSES, WorkspaceHydrateService
from src.domain.codegen.repository import CodegenRunRepository
from src.domain.codegen.run_chat_repository import RunChatMessageRepository
from src.domain.codegen.schemas import (
    CodegenRunCreate,
    CodegenRunResponse,
    CodegenRunSummary,
    SupplyUserInputsBody,
)
from src.domain.codegen.service import CodegenService
from src.domain.deployments.repository import DeployRepository, DeploymentTargetRepository, DomainRepository
from src.domain.deployments.schemas import DeployCreate, DeployResponse, DomainCreate, DomainResponse
from src.domain.deployments.service import DeployService, domain_response as _domain_response
from src.domain.instructions.disk_mirror import mirror_instruction_to_meta
from src.domain.instructions.repository import InstructionRepository, PromptRepository, PromptSessionRepository
from src.domain.previews.repository import PreviewRepository, RoutesManifestRepository
from src.domain.previews.schemas import PreviewCreate, PreviewResponse
from src.domain.previews.service import PreviewService
from src.domain.billing.repository import UsageLedgerRepository
from src.domain.projects.repository import ProjectRepository, ProjectSettingsRepository
from src.domain.prompt_media.paths import build_storage_relative_path
from src.domain.prompt_media.repository import PromptMediaRepository
from src.domain.revisions.repository import RevisionRepository, RevisionTagRepository
from src.domain.revisions.schemas import RevisionResponse, TagCreate, TagResponse
from src.domain.revisions.service import RevisionService
from src.integrations.transcription.whisper_openai import transcribe_audio_bytes
from src.workers.enqueue import enqueue_run_project_deploy


router = APIRouter(prefix="/api/projects/{project_id}", tags=["builder"])

_IDEM_TTL_SEC = 86400


async def _rate_limit(redis: aioredis.Redis, user_id: UUID, op: str, limit: int = 60) -> None:
    k = f"p2hs:rl:{op}:{user_id}"
    n = await redis.incr(k)
    if n == 1:
        await redis.expire(k, 60)
    if n > limit:
        raise RateLimitError(f"Too many requests for {op}")


def _idem_cache_key(user_id: UUID, route_key: str, idem: str) -> str:
    h = hashlib.sha256(f"{user_id}:{route_key}:{idem}".encode()).hexdigest()
    return f"p2hs:idem:{h}"


async def _idem_read(
    redis: aioredis.Redis, user_id: UUID, route_key: str, header_val: Optional[str]
) -> tuple[Optional[dict], Optional[str]]:
    if not header_val or not str(header_val).strip():
        return None, None
    ck = _idem_cache_key(user_id, route_key, str(header_val).strip())
    raw = await redis.get(ck)
    if raw:
        return json.loads(raw.decode() if isinstance(raw, bytes) else raw), ck
    return None, ck


async def _idem_write(redis: aioredis.Redis, ck: str, status_code: int, body: dict) -> None:
    await redis.setex(
        ck,
        _IDEM_TTL_SEC,
        json.dumps({"status_code": status_code, "body": body}),
    )


class ScreenshotRequest(BaseModel):
    url: str = Field(..., min_length=8, max_length=2048)


# --- Dependency: project_id with paid workspace + MFA policy ---
def _get_project_id(
    project_id: UUID = Depends(require_active_billing_for_project_id),
) -> UUID:
    return project_id


async def _require_run_in_project(project_id: UUID, run_id: UUID, db: AsyncSession):
    run = await CodegenRunRepository(db).get_by_id(run_id)
    if not run or run.project_id != project_id:
        raise NotFoundError("Run not found")
    return run


async def _require_revision_in_project(project_id: UUID, revision_id: UUID, db: AsyncSession):
    rev = await RevisionRepository(db).get_by_id(revision_id)
    if not rev or rev.project_id != project_id:
        raise NotFoundError("Revision not found")
    return rev


async def _require_preview_in_project(project_id: UUID, preview_id: UUID, db: AsyncSession):
    pe = await PreviewRepository(db).get_by_id(preview_id)
    if not pe or pe.project_id != project_id:
        raise NotFoundError("Preview not found")
    return pe


async def _require_deploy_in_project(project_id: UUID, deploy_id: UUID, db: AsyncSession):
    d = await DeployRepository(db).get_by_id(deploy_id)
    if not d or d.project_id != project_id:
        raise NotFoundError("Deploy not found")
    return d


# --- Workspace hydrate ---
async def _get_workspace_service(db: AsyncSession = Depends(get_db)) -> WorkspaceHydrateService:
    return WorkspaceHydrateService(
        project_repo=ProjectRepository(db),
        revision_repo=RevisionRepository(db),
        codegen_repo=CodegenRunRepository(db),
        prompt_session_repo=PromptSessionRepository(db),
        prompt_repo=PromptRepository(db),
        run_chat_repo=RunChatMessageRepository(db),
        preview_repo=PreviewRepository(db),
        manifest_repo=RoutesManifestRepository(db),
        deploy_repo=DeployRepository(db),
        domain_repo=DomainRepository(db),
        timeline_repo=TimelineEventRepository(db),
        changed_file_repo=ChangedFileRepository(db),
    )


# --- Codegen service (reused) ---
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
    from src.domain.manifest.repository import ManifestRunRepository
    from src.domain.manifest.service import ManifestService
    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)),
    )


# --- Revision service ---
class _ProjectFacadeForRevisions:
    def __init__(self, db: AsyncSession):
        self._project_repo = ProjectRepository(db)

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

    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 p.repo else None


async def _get_revision_service(db: AsyncSession = Depends(get_db)) -> RevisionService:
    return RevisionService(
        revision_repo=RevisionRepository(db),
        tag_repo=RevisionTagRepository(db),
        project_facade=_ProjectFacadeForRevisions(db),
    )


async def _get_preview_service(db: AsyncSession = Depends(get_db)) -> PreviewService:
    return PreviewService(
        preview_repo=PreviewRepository(db),
        manifest_repo=RoutesManifestRepository(db),
    )


async def _get_deploy_service(db: AsyncSession = Depends(get_db)) -> DeployService:
    return DeployService(
        deploy_repo=DeployRepository(db),
        domain_repo=DomainRepository(db),
        target_repo=DeploymentTargetRepository(db),
        project_repo=ProjectRepository(db),
        revision_repo=RevisionRepository(db),
    )


# ========== Workspace ==========
@router.get("/workspace", response_model=WorkspaceResponse)
async def get_workspace(
    project_id: UUID = Depends(_get_project_id),
    selected_run_id: Optional[UUID] = Query(None),
    selected_session_id: Optional[UUID] = Query(None),
    selected_route: Optional[str] = Query(None),
    user: User = Depends(get_current_user),
    service: WorkspaceHydrateService = Depends(_get_workspace_service),
):
    """Primary builder hydrate endpoint - returns full workspace snapshot."""
    return await service.hydrate(
        project_id,
        selected_run_id=selected_run_id,
        selected_session_id=selected_session_id,
        selected_route=selected_route,
    )


# ========== Sessions / Prompts ==========
class SessionCreate(BaseModel):
    title: Optional[str] = None


class PromptMessageCreate(BaseModel):
    content: str
    context: Optional[dict] = None
    session_id: Optional[UUID] = None


class ElementContext(BaseModel):
    """Visual click context from the preview iframe overlay."""
    x_pct: Optional[float] = None   # 0–100, % from left edge of viewport
    y_pct: Optional[float] = None   # 0–100, % from top edge of viewport
    section_hint: Optional[str] = None  # e.g. "hero", "navbar", "footer", "top", "bottom"
    element_hint: Optional[str] = None  # visible text or label near the click


class PromptSubmit(BaseModel):
    """Canonical prompt submission - one atomic backend action."""
    content: str = ""
    # Client-side speech-to-text (optional); combined with ``content`` and attachments.
    voice_transcript: Optional[str] = None
    # From POST …/prompt-media/upload (same project), before submit.
    attachment_ids: Optional[list[UUID]] = None
    context: Optional[dict] = None
    session_id: Optional[UUID] = None
    selected_route: Optional[str] = None
    viewport: Optional[str] = None
    element_context: Optional[ElementContext] = None  # set when user clicks on preview
    # Continuation (same semantics as POST …/chat) — required when a run is already active.
    run_id: Optional[UUID] = None
    clarification_answers: Optional[dict[str, Any]] = None
    approve_plan: bool = False

    @model_validator(mode="after")
    def _require_some_input(self) -> "PromptSubmit":
        text = (self.content or "").strip()
        voice = (self.voice_transcript or "").strip()
        ids = self.attachment_ids or []
        structured = self.clarification_answers is not None
        if self.run_id is not None:
            if text or voice or ids or structured or self.approve_plan:
                return self
            raise ValueError(
                "Continuation requires content, voice_transcript, attachment_ids, "
                "clarification_answers, or approve_plan"
            )
        if not text and not voice and not ids:
            raise ValueError(
                "Provide content, voice_transcript, and/or attachment_ids"
            )
        return self


class PromptSubmitResponse(BaseModel):
    """Response from prompt/submit - coherent run + session + message ids."""
    prompt_id: UUID
    run_id: UUID
    session_id: UUID
    message: str = "Run enqueued"


def _prompt_submit_to_chat_request(body: PromptSubmit) -> ChatRequest:
    ctx: dict = dict(body.context) if body.context else {}
    if body.selected_route:
        ctx["route"] = body.selected_route
    if body.viewport:
        ctx["viewport"] = body.viewport
    if body.element_context:
        ctx["selected_elements"] = [body.element_context.model_dump()]
    return ChatRequest(
        message=body.content or "",
        attachments=body.attachment_ids,
        context=ctx or None,
        session_id=body.session_id,
        voice_transcript=body.voice_transcript,
        run_id=body.run_id,
        clarification_answers=body.clarification_answers,
        approve_plan=body.approve_plan,
    )


@router.post("/chat", response_model=ChatResponse)
async def post_project_chat(
    body: ChatRequest,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
    redis: aioredis.Redis = Depends(get_redis_client),
    service: CodegenService = Depends(_get_codegen_service),
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
):
    """v2 canonical chat entry (single-run lifecycle, explicit run_id for continuation)."""
    await _rate_limit(redis, user.id, "chat", limit=120)
    route_key = f"chat:{project_id}"
    cached, ck = await _idem_read(redis, user.id, route_key, idempotency_key)
    if cached and int(cached["status_code"]) == 200:
        return ChatResponse.model_validate(cached["body"])
    try:
        out = await handle_project_chat(
            db, project_id, user, body, codegen_service=service
        )
    except ValueError as e:
        raise ValidationError(str(e)) from e
    if ck:
        await _idem_write(
            redis,
            ck,
            200,
            out.model_dump(mode="json"),
        )
    return out


@router.post("/transcribe")
async def post_transcribe(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
    redis: aioredis.Redis = Depends(get_redis_client),
    file: UploadFile = File(...),
    language: Optional[str] = Form(None),
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
):
    """Stateless Whisper transcription; bills ``audio_transcription_seconds`` on success only."""
    await _rate_limit(redis, user.id, "transcribe", limit=30)
    route_key = f"transcribe:{project_id}"
    cached, ck = await _idem_read(redis, user.id, route_key, idempotency_key)
    if cached:
        return JSONResponse(
            status_code=int(cached["status_code"]),
            content=cached["body"],
        )

    settings = get_settings()
    mx = int(getattr(settings, "prompt_media_max_bytes", 25 * 1024 * 1024))
    audio = await file.read()
    if len(audio) > mx:
        raise ValidationError("Audio file too large")
    if len(audio) == 0:
        raise ValidationError("Empty audio file")

    proj = await ProjectRepository(db).get_by_id(project_id)
    if not proj:
        raise NotFoundError("Project not found")

    try:
        text, duration = await transcribe_audio_bytes(
            api_key=settings.openai_api_key,
            audio_bytes=audio,
            filename=file.filename or "audio.webm",
            language=language,
        )
    except ValueError as e:
        if str(e) == "silent_audio":
            err_body = {
                "error": "silent_audio",
                "message": "No speech was detected in the uploaded audio.",
            }
            if ck:
                await _idem_write(redis, ck, 422, err_body)
            return JSONResponse(status_code=422, content=err_body)
        raise ValidationError(str(e)) from e

    await UsageLedgerRepository(db).record(
        workspace_id=proj.workspace_id,
        event_type="audio_transcription_seconds",
        quantity=max(1, int(math.ceil(duration))),
        unit_label="seconds",
        project_id=project_id,
        metadata={"duration_seconds": duration},
    )
    await db.commit()
    ok_body = {"text": text, "duration_seconds": duration}
    if ck:
        await _idem_write(redis, ck, 200, ok_body)
    return JSONResponse(content=ok_body)


@router.post("/screenshot")
async def post_screenshot(
    body: ScreenshotRequest,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
    redis: aioredis.Redis = Depends(get_redis_client),
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key"),
):
    """Fetch HTTPS URL (SSRF-safe), store image as ``prompt_media`` (for composer attachment)."""
    await _rate_limit(redis, user.id, "screenshot", limit=20)
    route_key = f"screenshot:{project_id}"
    cached, ck = await _idem_read(redis, user.id, route_key, idempotency_key)
    if cached:
        return JSONResponse(
            status_code=int(cached["status_code"]),
            content=cached["body"],
        )

    settings = get_settings()
    proj = await ProjectRepository(db).get_by_id(project_id)
    if not proj:
        raise NotFoundError("Project not found")
    max_bytes = min(int(getattr(settings, "prompt_media_max_bytes", 25 * 1024 * 1024)), 15 * 1024 * 1024)
    try:
        raw, ct = await fetch_url_bytes(body.url, max_bytes=max_bytes)
    except ValueError as e:
        raise ValidationError(str(e)) from e
    if not (ct or "").lower().startswith("image/"):
        raise ValidationError("URL did not return an image content type")

    asset_id = uuid4()
    storage_fn = f"{asset_id}_screenshot.png"
    rel = build_storage_relative_path(
        proj.workspace_id,
        project_id,
        storage_fn,
        session_id=None,
    )
    abs_path = os.path.join(
        os.path.normpath(settings.prompt_media_upload_root),
        rel.replace("/", os.sep),
    )
    os.makedirs(os.path.dirname(abs_path), exist_ok=True)

    def _write() -> None:
        with open(abs_path, "wb") as out:
            out.write(raw)

    import asyncio

    await asyncio.to_thread(_write)

    repo = PromptMediaRepository(db)
    row = await repo.create(
        project_id=project_id,
        uploaded_by=user.id,
        kind="image",
        storage_relative_path=rel.replace("\\", "/"),
        original_filename="screenshot.png",
        mime_type=ct,
        byte_size=len(raw),
        transcript=None,
        asset_id=asset_id,
    )
    await db.commit()
    ok_body = {"prompt_media_id": str(row.id), "mime_type": ct, "byte_size": len(raw)}
    if ck:
        await _idem_write(redis, ck, 200, ok_body)
    return JSONResponse(content=ok_body)


@router.post("/prompt/submit", response_model=PromptSubmitResponse)
async def submit_prompt(
    body: PromptSubmit,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
    service: CodegenService = Depends(_get_codegen_service),
):
    """Deprecated path: delegates to ``POST …/chat`` (legacy response shape)."""
    try:
        chat_body = _prompt_submit_to_chat_request(body)
        out = await handle_project_chat(
            db, project_id, user, chat_body, codegen_service=service
        )
    except ValueError as e:
        raise ValidationError(str(e)) from e
    return PromptSubmitResponse(
        prompt_id=out.message_id,
        run_id=out.run_id,
        session_id=out.session_id,
        message="Run enqueued",
    )


@router.get("/sessions")
async def list_sessions(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    sessions = await PromptSessionRepository(db).list_for_project(project_id)
    return [{"id": str(s.id), "title": s.title, "created_at": s.created_at.isoformat()} for s in sessions]


@router.post("/sessions")
async def create_session(
    body: SessionCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    s = await PromptSessionRepository(db).create(project_id, user.id, body.title)
    return {"id": str(s.id), "project_id": str(s.project_id), "title": s.title}


@router.get("/prompt/messages")
async def get_prompt_messages(
    project_id: UUID = Depends(_get_project_id),
    session_id: Optional[UUID] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    session_repo = PromptSessionRepository(db)
    prompt_repo = PromptRepository(db)
    project_repo = ProjectRepository(db)
    from src.domain.codegen.run_chat_repository import RunChatMessageRepository

    proj = await project_repo.get_by_id(project_id)
    if not proj:
        from src.core.exceptions import NotFoundError
        raise NotFoundError("Project not found")
    sid = session_id or proj.active_session_id
    if not sid:
        return {"messages": []}
    sess = await session_repo.get_by_id(sid)
    if not sess or sess.project_id != project_id:
        return {"messages": []}
    unified = await RunChatMessageRepository(db).list_for_session(sid)
    prompts = await prompt_repo.list_for_session(sid)
    sender_labels = await prompt_repo.get_sender_labels(prompts)
    if unified:
        covered = {m.prompt_id for m in unified if m.prompt_id}
        rows: list[dict] = [
            {
                "id": str(m.id),
                "sender_label": sender_labels.get(m.prompt_id) if m.role == "user" else None,
                "role": m.role,
                "type": m.message_type,
                "content": m.content,
                "payload": m.payload,
                "run_id": str(m.run_id),
                "prompt_id": str(m.prompt_id) if m.prompt_id else None,
                "created_at": m.created_at.isoformat(),
            }
            for m in unified
        ]
        for p in prompts:
            if p.id in covered:
                continue
            rows.append(
                {
                    "id": str(p.id),
                    "sender_label": sender_labels.get(p.id),
                    "role": "user",
                    "type": "message",
                    "content": p.content,
                    "context": p.context,
                    "response": p.response,
                    "run_id": None,
                    "prompt_id": str(p.id),
                    "created_at": p.created_at.isoformat(),
                }
            )
        rows.sort(key=lambda x: x["created_at"])
        return {"session_id": str(sid), "messages": rows}
    return {
        "session_id": str(sid),
        "messages": [
            {
                "id": str(p.id),
                "sender_label": sender_labels.get(p.id),
                "role": "user",
                "type": "message",
                "content": p.content,
                "context": p.context,
                "response": p.response,
                "run_id": None,
                "prompt_id": str(p.id),
                "created_at": p.created_at.isoformat(),
            }
            for p in prompts
        ],
    }


@router.post("/prompt/messages")
async def add_prompt_message(
    body: PromptMessageCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    session_repo = PromptSessionRepository(db)
    prompt_repo = PromptRepository(db)
    project_repo = ProjectRepository(db)
    proj = await project_repo.get_by_id(project_id)
    if not proj:
        raise NotFoundError("Project not found")
    sid = body.session_id or proj.active_session_id
    if not sid:
        s = await session_repo.create(project_id, user.id, None)
        sid = s.id
        await project_repo.update(project_id, active_session_id=sid)
    sess = await session_repo.get_by_id(sid)
    if not sess or sess.project_id != project_id:
        raise NotFoundError("Session not found")
    p = await prompt_repo.create(sid, body.content, user.id, body.context)
    return {"id": str(p.id), "content": p.content, "context": p.context}


# ========== Runs ==========
@router.get("/runs", response_model=list[CodegenRunSummary])
async def list_runs(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(_get_codegen_service),
):
    runs = await service.list_runs(project_id)
    return [CodegenRunSummary.from_run(r) for r in runs]


@router.post("/runs", response_model=CodegenRunResponse)
async def create_run(
    body: CodegenRunCreate,
    project_id: UUID = Depends(_get_project_id),
    selected_route: Optional[str] = Query(None),
    viewport: Optional[str] = Query(None),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(_get_codegen_service),
):
    run = await service.create_run(
        project_id=project_id,
        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,
    )
    from src.workers.enqueue import enqueue_codegen_run
    await enqueue_codegen_run(str(run.id))
    return CodegenRunResponse.from_run(run)


@router.get("/runs/{run_id}", response_model=CodegenRunResponse)
async def get_run(
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    r = await _require_run_in_project(project_id, run_id, db)
    return CodegenRunResponse.from_run(r)


@router.post("/runs/{run_id}/cancel", response_model=CodegenRunResponse)
async def cancel_run_builder(
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(_get_codegen_service),
):
    canceled = await service.cancel_run(run_id=run_id, project_id=project_id)
    return CodegenRunResponse.from_run(canceled)


@router.post("/runs/{run_id}/retry", response_model=CodegenRunResponse)
async def retry_run_builder(
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(_get_codegen_service),
):
    new_run = await service.retry_run(run_id=run_id, project_id=project_id, 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.post("/runs/{run_id}/supply-user-inputs", response_model=CodegenRunResponse)
async def supply_user_inputs_builder(
    body: SupplyUserInputsBody,
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: CodegenService = Depends(_get_codegen_service),
):
    new_run = await service.supply_user_inputs(
        run_id=run_id,
        project_id=project_id,
        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_id))
    return CodegenRunResponse.from_run(new_run)


# ========== Timeline ==========
_TERMINAL_STATUSES = _TERMINAL_RUN_STATUSES


@router.get("/run/timeline")
async def get_run_timeline(
    project_id: UUID = Depends(_get_project_id),
    run_id: Optional[UUID] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    timeline_repo = TimelineEventRepository(db)
    run = None
    if run_id:
        run = await CodegenRunRepository(db).get_by_id(run_id)
        events = await timeline_repo.list_for_run(project_id, run_id)
    else:
        events = await timeline_repo.list_for_project(project_id, limit=50)
    run_status = run.status if run else None
    return {
        "run_status": run_status,
        "is_terminal": run_status in _TERMINAL_STATUSES if run_status else None,
        "events": [
            {"id": str(e.id), "event_type": e.event_type, "phase": e.phase, "title": e.title, "sequence_no": e.sequence_no, "created_at": e.created_at.isoformat()}
            for e in events
        ],
    }


@router.get("/runs/{run_id}/timeline")
async def get_run_timeline_by_id(
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    run = await _require_run_in_project(project_id, run_id, db)
    timeline_repo = TimelineEventRepository(db)
    events = await timeline_repo.list_for_run(project_id, run_id)
    return {
        "run_id": str(run_id),
        "run_status": run.status,
        "is_terminal": run.status in _TERMINAL_STATUSES,
        "events": [
            {"id": str(e.id), "event_type": e.event_type, "phase": e.phase, "title": e.title, "sequence_no": e.sequence_no, "created_at": e.created_at.isoformat()}
            for e in events
        ],
    }


# ========== Changes ==========
@router.get("/run/changes")
async def get_run_changes(
    project_id: UUID = Depends(_get_project_id),
    run_id: Optional[UUID] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Get changes for latest or specified run."""
    changed_repo = ChangedFileRepository(db)
    codegen_repo = CodegenRunRepository(db)
    rid = run_id
    if not rid:
        runs = await codegen_repo.list_for_project(project_id)
        rid = runs[0].id if runs else None
    if not rid:
        return {"changed_files": []}
    files = await changed_repo.list_for_run(project_id, rid)
    return {"changed_files": [{"file_path": f.file_path, "change_type": f.change_type, "summary": f.summary} for f in files]}


@router.get("/runs/{run_id}/changes")
async def get_run_changes_by_id(
    project_id: UUID = Depends(_get_project_id),
    run_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    await _require_run_in_project(project_id, run_id, db)
    changed_repo = ChangedFileRepository(db)
    files = await changed_repo.list_for_run(project_id, run_id)
    return {"changed_files": [{"file_path": f.file_path, "change_type": f.change_type, "summary": f.summary} for f in files]}


@router.get("/revision/changes")
async def get_revision_changes(
    project_id: UUID = Depends(_get_project_id),
    revision_id: Optional[UUID] = Query(None),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    """Get changes for latest or specified revision."""
    changed_repo = ChangedFileRepository(db)
    revision_repo = RevisionRepository(db)
    rid = revision_id
    if not rid:
        rev = await revision_repo.get_latest(project_id)
        rid = rev.id if rev else None
    if not rid:
        return {"changed_files": []}
    files = await changed_repo.list_for_revision(project_id, rid)
    return {"changed_files": [{"file_path": f.file_path, "change_type": f.change_type, "summary": f.summary} for f in files]}


@router.get("/revisions/{revision_id}/changes")
async def get_revision_changes_by_id(
    project_id: UUID = Depends(_get_project_id),
    revision_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    await _require_revision_in_project(project_id, revision_id, db)
    changed_repo = ChangedFileRepository(db)
    files = await changed_repo.list_for_revision(project_id, revision_id)
    return {"changed_files": [{"file_path": f.file_path, "change_type": f.change_type, "summary": f.summary} for f in files]}


# ========== Revisions ==========
@router.get("/revisions", response_model=list[RevisionResponse])
async def list_revisions(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: RevisionService = Depends(_get_revision_service),
):
    revs = await service.list_revisions(project_id)
    return [RevisionResponse.model_validate(r) for r in revs]


@router.get("/revisions/{revision_id}", response_model=RevisionResponse)
async def get_revision(
    project_id: UUID = Depends(_get_project_id),
    revision_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    rev = await _require_revision_in_project(project_id, revision_id, db)
    return RevisionResponse.model_validate(rev)


@router.post("/revisions/{revision_id}/restore", response_model=RevisionResponse)
async def restore_revision(
    project_id: UUID = Depends(_get_project_id),
    revision_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: RevisionService = Depends(_get_revision_service),
    db: AsyncSession = Depends(get_db),
):
    await _require_revision_in_project(project_id, revision_id, db)
    rev = await service.restore_revision(project_id, revision_id, user.id)
    return RevisionResponse.model_validate(rev)


@router.post("/revisions/{revision_id}/tag", response_model=TagResponse)
async def tag_revision(
    body: TagCreate,
    project_id: UUID = Depends(_get_project_id),
    revision_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: RevisionService = Depends(_get_revision_service),
    db: AsyncSession = Depends(get_db),
):
    await _require_revision_in_project(project_id, revision_id, db)
    t = await service.tag_revision(revision_id, body.tag, user.id)
    return TagResponse(id=t.id, revision_id=t.revision_id, tag=t.tag, created_at=t.created_at)


# ========== Previews ==========
@router.get("/previews", response_model=list[PreviewResponse])
async def list_previews(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: PreviewService = Depends(_get_preview_service),
):
    previews = await service.list_previews(project_id)
    return [PreviewResponse.model_validate(p) for p in previews]


@router.post("/previews", response_model=PreviewResponse)
async def create_preview(
    body: PreviewCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: PreviewService = Depends(_get_preview_service),
    db: AsyncSession = Depends(get_db),
):
    await _require_revision_in_project(project_id, body.revision_id, db)
    pe = await service.create_preview(project_id, body.revision_id)
    from src.workers.enqueue import enqueue_provision_preview_for_revision
    await enqueue_provision_preview_for_revision(
        str(pe.id), str(project_id), str(body.revision_id)
    )
    return PreviewResponse.model_validate(pe)


@router.get("/previews/{preview_id}", response_model=PreviewResponse)
async def get_preview(
    project_id: UUID = Depends(_get_project_id),
    preview_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    pe = await _require_preview_in_project(project_id, preview_id, db)
    return PreviewResponse.model_validate(pe)


# ========== Routes ==========
@router.get("/routes")
async def get_routes(
    project_id: UUID = Depends(_get_project_id),
    revision_id: Optional[UUID] = Query(None),
    user: User = Depends(get_current_user),
    service: PreviewService = Depends(_get_preview_service),
):
    return await service.get_routes_manifest(project_id, revision_id)


# ========== Instructions ==========
@router.get("/instructions")
async def get_instructions(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    content = await InstructionRepository(db).get_current_content(project_id)
    return {"content": content}


class InstructionVersionCreate(BaseModel):
    content: str


@router.post("/instructions/versions")
async def create_instruction_version(
    body: InstructionVersionCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    repo = InstructionRepository(db)
    v = await repo.create_version(project_id, body.content, None)
    await repo.set_current(project_id, v.id)
    proj_row = await ProjectRepository(db).get_by_id(project_id)
    if proj_row:
        mirror_instruction_to_meta(proj_row.workspace_id, project_id, body.content)
    return {"id": str(v.id), "version_number": v.version_number}


# ========== Deploys ==========
@router.get("/deploys", response_model=list[DeployResponse])
async def list_deploys(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: DeployService = Depends(_get_deploy_service),
):
    deploys = await service.list_deploys(project_id)
    return [DeployResponse.model_validate(d) for d in deploys]


@router.post("/deploys", response_model=DeployResponse)
async def create_deploy(
    body: DeployCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: DeployService = Depends(_get_deploy_service),
    db: AsyncSession = Depends(get_db),
):
    revision_id = body.revision_id
    if revision_id is None:
        latest = await RevisionRepository(db).get_latest(project_id)
        if not latest:
            raise NotFoundError("No revision found for this project")
        revision_id = latest.id
    await _require_revision_in_project(project_id, revision_id, db)
    try:
        d = await service.create_deploy(project_id, revision_id, body.environment, user.id)
        await db.commit()
    except IntegrityError as exc:
        await db.rollback()
        if "uq_project_deploy_active" not in str(exc.orig):
            raise
        raise ConflictError(
            f"A {body.environment} deployment is already in progress. "
            "Wait for it to succeed or fail before starting another deployment."
        ) from exc
    await enqueue_run_project_deploy(str(d.id))
    return DeployResponse.model_validate(d)


@router.get("/deploys/{deploy_id}", response_model=DeployResponse)
async def get_deploy(
    project_id: UUID = Depends(_get_project_id),
    deploy_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    deploy = await _require_deploy_in_project(project_id, deploy_id, db)
    return DeployResponse.model_validate(deploy)


@router.post("/deploys/{deploy_id}/rollback", response_model=DeployResponse)
async def rollback_deploy(
    project_id: UUID = Depends(_get_project_id),
    deploy_id: UUID = Path(...),
    user: User = Depends(get_current_user),
    service: DeployService = Depends(_get_deploy_service),
    db: AsyncSession = Depends(get_db),
):
    await _require_deploy_in_project(project_id, deploy_id, db)
    d = await service.rollback(deploy_id, user.id)
    await db.commit()
    await enqueue_run_project_deploy(str(d.id))
    return DeployResponse.model_validate(d)


# ========== Domains ==========
@router.get("/domains", response_model=list[DomainResponse])
async def list_domains(
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db),
):
    domains = await DomainRepository(db).list_for_project(project_id)
    return [_domain_response(d) for d in domains]


@router.post("/domains", response_model=DomainResponse)
async def add_domain(
    body: DomainCreate,
    project_id: UUID = Depends(_get_project_id),
    user: User = Depends(get_current_user),
    service: DeployService = Depends(_get_deploy_service),
):
    d = await service.add_domain(project_id, body.domain, body.environment)
    return _domain_response(d)
