"""POST /api/proposals/generate — 9th ProposalKind executor.

Auto-approves and dispatches asyncio.create_subprocess_exec([python,
recoil/pipeline/tools/dispatch_cli.py, ...]). Pipes stdout/stderr through
asyncio.create_task that emits SSE events to scope='engine/generation'.
Preserves CP-5 audit trail (dispatch_cli.py imports in-process dispatch()
which writes _dispatch_logs/receipts.jsonl).

Per Fork 4 lock in console-v2-meta-diagnosis SYNTHESIS: subprocess.Popen with
PIPE'd stdout + asyncio reader is the chosen pattern — NOT named pipes, NOT
in-process dispatch (which would block the FastAPI worker).

Argv surface — inspected against dispatch_cli.py at build time (Phase 5):

  dispatch_cli.py recognizes:
    --project (required)   --shot / --shots / --per-shot
    --mode {standard,action,coverage}
    --model (default kling-v3)        --prompt
    --duration (int, default 5)       --dry-run
    ... and many model-specific flags (--elements, --image-refs,
    --seedance-refs, --ref-video, --start-frame, --end-frame, etc.)

  It does NOT accept --episode (episode is implicit from --shot ID prefix
  or project structure) and does NOT accept --modality (modality is
  inferred from --model + --mode in dispatch_cli.py's routing).

  _build_argv below therefore drops `episode` and `modality` from the
  outgoing argv. They remain on the inbound GenerationRequest so the
  frontend can keep its existing call shape (Phase 6 will use them for
  client-side state); they're logged but not forwarded.
"""
from __future__ import annotations

import asyncio
import sys
import uuid
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field

from recoil.api.eventbus import BUS

router = APIRouter()

# Resolved once at import. dispatch_cli.py lives at
# recoil/pipeline/tools/dispatch_cli.py. __file__ here is
# recoil/api/generation_routes.py → parent.parent == recoil/.
DISPATCH_CLI_PATH = (
    Path(__file__).resolve().parent.parent / "pipeline" / "tools" / "dispatch_cli.py"
)


class GenerationRequest(BaseModel):
    """Inbound shape for POST /api/proposals/generate.

    Accepts both snake_case and camelCase (frontend sends camelCase).
    """

    model_config = ConfigDict(populate_by_name=True)

    project: str
    episode: Optional[int] = None
    shot_id: Optional[str] = Field(default=None, alias="shotId")
    model: Optional[str] = None
    modality: str = "video_i2v"
    dry_run: bool = Field(default=False, alias="dryRun")


class GenerationAccepted(BaseModel):
    model_config = ConfigDict(populate_by_name=True)

    proposal_id: str = Field(alias="proposalId")
    pid: int
    scope: str  # "engine/generation"


def _build_argv(req: GenerationRequest) -> list[str]:
    """Translate the request to a dispatch_cli.py argv.

    Matches dispatch_cli.py's actual CLI surface (verified Phase 5 build):
      --project (required)
      --shot   (singular shot id)
      --model  (optional; dispatch_cli defaults to kling-v3)
      --dry-run (flag)

    `episode` and `modality` are intentionally NOT forwarded —
    dispatch_cli.py does not accept those flags. They live on the request
    for frontend / audit purposes only.
    """
    argv: list[str] = [sys.executable, str(DISPATCH_CLI_PATH), "--project", req.project]
    if req.shot_id:
        argv += ["--shot", req.shot_id]
    if req.model:
        argv += ["--model", req.model]
    if req.dry_run:
        argv += ["--dry-run"]
    return argv


async def _drain_pipe(
    stream: Optional[asyncio.StreamReader], scope: str, severity: str
) -> None:
    """Read lines from `stream` and emit them as BUS events under `scope`."""
    if stream is None:
        return
    while True:
        try:
            line = await stream.readline()
        except Exception:
            return
        if not line:
            return
        text = line.decode("utf-8", errors="replace").rstrip("\n")
        BUS.emit_sync(
            severity=severity,  # type: ignore[arg-type]
            scope=scope,
            summary=text,
            payload={"source": "dispatch_cli"},
        )


async def _supervise(proc: "asyncio.subprocess.Process", proposal_id: str) -> None:
    """Wait on the process; emit a final event with exit code."""
    rc = await proc.wait()
    BUS.emit_sync(
        severity="success" if rc == 0 else "failure",  # type: ignore[arg-type]
        scope="engine/generation",
        summary=f"dispatch_cli exited rc={rc}",
        payload={"proposal_id": proposal_id, "rc": rc},
    )


@router.post(
    "/proposals/generate",
    response_model=GenerationAccepted,
    status_code=status.HTTP_202_ACCEPTED,
)
async def post_proposals_generate(req: GenerationRequest) -> GenerationAccepted:
    if not DISPATCH_CLI_PATH.exists():
        raise HTTPException(
            status_code=500,
            detail=f"dispatch_cli.py missing at {DISPATCH_CLI_PATH}",
        )
    argv = _build_argv(req)
    proposal_id = f"gen_{uuid.uuid4().hex[:12]}"
    proc = await asyncio.create_subprocess_exec(
        *argv,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    # Spawn drainers + supervisor, then return 202 IMMEDIATELY.
    asyncio.create_task(_drain_pipe(proc.stdout, "engine/generation", "info"))
    asyncio.create_task(_drain_pipe(proc.stderr, "engine/generation", "warning"))
    asyncio.create_task(_supervise(proc, proposal_id))
    BUS.emit_sync(
        severity="info",
        scope="engine/generation",
        summary=f"dispatch_cli spawned pid={proc.pid}",
        payload={"proposal_id": proposal_id, "argv": argv},
    )
    return GenerationAccepted(
        proposalId=proposal_id, pid=proc.pid, scope="engine/generation"
    )


# Alias — Workspace's flow used POST /api/generate/{project}; Console v2
# keeps both surfaces.
@router.post(
    "/generate/{project}",
    response_model=GenerationAccepted,
    status_code=status.HTTP_202_ACCEPTED,
)
async def post_generate_project(
    project: str, req: GenerationRequest
) -> GenerationAccepted:
    if req.project and req.project != project:
        raise HTTPException(status_code=422, detail="project path/body mismatch")
    req2 = req.model_copy(update={"project": project})
    return await post_proposals_generate(req2)
