"""FastAPI service: controls -> the real alteration engine -> a RenderPlan.

The simulator's alteration surface (reconciled slice). It calls the canonical
player.alteration.plan_alteration; the browser only renders. The selection-era
endpoints (/api/select, /api/catalog/meta) and the X-ray are retired.
"""

from __future__ import annotations

import hashlib
import json
import os
import random
import time
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field

from hef.selection import Coordinate
from player.alteration import (
    DEFAULT_CALIBRATION,
    Calibration,
    plan_alteration,
    render_plan_to_dict,
)
from player.audio import AUDIO_SOURCES, VISUAL_POSITIONS, resolve_audio, resolve_visual
from player.ring import (
    DEFAULT_FAST_SPIN_THRESHOLD,
    ResolvedMove,
    advance_ring,
    pick_clip_id,
    resolve_move,
    scale_at,
)
from simulator.clips import (
    load_manifest,
    load_ring,
    resolved_move_to_dict,
    ring_to_dict,
)

STATIC_DIR = Path(__file__).parent / "static"
MEDIA_DIR = Path(__file__).parent / "sample_media"
DEFAULT_MANIFEST = MEDIA_DIR / "manifest.json"

# Per-process boot token: changes on every server restart so the dev live-reload
# (below) also fires when Python code changes (which needs a restart), not just
# when a static asset's mtime changes.
_BOOT_ID = f"{os.getpid()}-{int(time.time())}"


def _asset_version() -> str:
    """A short digest of the renderer assets' mtimes + this process's boot token.
    Changes whenever app.js/style.css/index.html change on disk OR the server is
    restarted — the signal the browser polls to know it's running stale code."""
    parts = [_BOOT_ID]
    for name in ("app.js", "style.css", "index.html"):
        try:
            parts.append(f"{name}:{(STATIC_DIR / name).stat().st_mtime_ns}")
        except OSError:
            parts.append(f"{name}:?")
    return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]


# Content-hash tokens for served media, so the client can append `?v=<hash>` to a
# /media URL. A clip re-baked under the SAME path (e.g. a re-sourced cosmos base)
# changes its hash → its URL → a fresh fetch, busting any cached prior bytes
# permanently (even an immutable-pinned entry a plain reload can't revalidate).
# Cached by (mtime_ns, size) so the full-file hash is recomputed only when the
# file actually changes — a re-bake is picked up without a server restart.
_media_hash_cache: dict[str, tuple[int, int, str]] = {}


def _media_version(rel: str) -> Optional[str]:
    """Short content hash of the media file at `rel` under MEDIA_DIR, or None if
    it's absent. Cheap on repeat calls: re-hashes only when (mtime, size) change."""
    path = MEDIA_DIR / rel
    try:
        st = path.stat()
    except OSError:
        return None
    cached = _media_hash_cache.get(rel)
    if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size:
        return cached[2]
    h = hashlib.sha1()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    token = h.hexdigest()[:12]
    _media_hash_cache[rel] = (st.st_mtime_ns, st.st_size, token)
    return token


class ControlsModel(BaseModel):
    visual: str
    audio: str
    left: int = Field(ge=0, le=4)
    right: int = Field(ge=0, le=4)
    dark: int = Field(ge=0, le=4)
    light: int = Field(ge=0, le=4)
    volume: int = Field(ge=0, le=4)
    brightness: int = Field(ge=0, le=4)


class CalibrationModel(BaseModel):
    mood_gain: float = 1.0
    overlay_gain: float = 1.0
    dream_gain: float = 1.0


class AlterationRequest(BaseModel):
    controls: ControlsModel
    altitude_index: int = 0
    calibration: Optional[CalibrationModel] = None


class RingAdvanceRequest(BaseModel):
    from_index: int = 0
    delta: int
    from_clip_id: str = ""


class AuthorTrackRequest(BaseModel):
    """Author mode: propagate a hand-seeded box on a clip into a keyframed track
    (content-pipeline §11.5). `seed_box` + `seed_t` are normalized."""

    clip_id: str
    key: str
    seed_box: list[float] = Field(min_length=4, max_length=4)
    seed_t: float = Field(default=0.0, ge=0.0, le=1.0)
    salience: int = Field(default=4, ge=1, le=4)
    n_keyframes: int = Field(default=5, ge=2, le=20)
    max_frames: Optional[int] = None


class AuthorClipRequest(BaseModel):
    """Author mode: persist the authored labels/affect/strings for an existing
    pool clip into the manifest (geometry from the tracker, semantics by hand)."""

    clip_id: str
    annotations: list = []
    affect: list = []
    strings: dict = {}


def _load_clips(manifest_path: Optional[Path]):
    path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
    if path.exists():
        return load_manifest(path)
    return []


def _load_ring(manifest_path: Optional[Path]):
    path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
    if path.exists():
        return load_ring(path)
    return None


def create_app(manifest_path: Optional[Path] = None) -> FastAPI:
    app = FastAPI(title="HEF Alteration Simulator")
    app.state.manifest_path = Path(manifest_path) if manifest_path else DEFAULT_MANIFEST
    app.state.clips = _load_clips(manifest_path)
    app.state.ring = _load_ring(manifest_path)

    @app.post("/api/alteration")
    def api_alteration(req: AlterationRequest):
        c = req.controls
        if c.visual not in VISUAL_POSITIONS:
            raise HTTPException(status_code=422, detail=f"invalid visual {c.visual!r}")
        if c.audio not in AUDIO_SOURCES:
            raise HTTPException(status_code=422, detail=f"invalid audio {c.audio!r}")
        coord = Coordinate(c.left, c.right, c.dark, c.light)
        cal = (
            Calibration(
                mood_gain=req.calibration.mood_gain,
                overlay_gain=req.calibration.overlay_gain,
                dream_gain=req.calibration.dream_gain,
            )
            if req.calibration
            else DEFAULT_CALIBRATION
        )
        plan = plan_alteration(coord, cal)
        # Resolve the soundtrack url against the CURRENT altitude (server-side, audio
        # spec §6.1). White-noise/off ignore the scale; soundtrack follows it.
        scale_audio = ""
        if app.state.ring is not None:
            scale_audio = scale_at(app.state.ring, req.altitude_index).audio
        audio = resolve_audio(c.audio, scale_audio=scale_audio)
        return {
            "plan": render_plan_to_dict(plan),
            "render": {
                "video": {"shown": resolve_visual(c.visual)},
                "audio": {
                    "source": audio.source,
                    "url": audio.url,
                    "altitude_coupled": audio.altitude_coupled,
                },
            },
        }

    @app.get("/dev/version")
    def dev_version():
        # Dev live-reload signal: the browser polls this and reloads when it
        # changes, so it never silently runs a stale renderer during iteration.
        return {"version": _asset_version()}

    @app.get("/api/clips")
    def api_clips():
        return {"clips": [c.to_dict() for c in app.state.clips]}

    @app.get("/api/media-versions")
    def api_media_versions():
        """Per-file content-hash tokens the client appends to /media URLs as
        `?v=<hash>`. Covers every served file: each clip's base footage plus every
        ring transition morph (both directions are explicit entries). A re-baked
        clip's hash changes, so its URL changes and the browser refetches — a
        permanent cache-bust."""
        files = {c.base_file for c in app.state.clips}
        if app.state.ring is not None:
            for t in app.state.ring.transitions:
                files.add(t.file)
        versions = {}
        for f in sorted(files):
            v = _media_version(f)
            if v:
                versions[f] = v
        return {"versions": versions}

    @app.get("/api/ring")
    def api_ring():
        if app.state.ring is None:
            raise HTTPException(status_code=404, detail="no scale ring in manifest")
        return ring_to_dict(app.state.ring, app.state.clips)

    @app.post("/api/ring/advance")
    def api_ring_advance(req: RingAdvanceRequest):
        if app.state.ring is None:
            raise HTTPException(status_code=404, detail="no scale ring in manifest")
        move = advance_ring(
            app.state.ring,
            req.from_index,
            req.delta,
            fast_spin_threshold=DEFAULT_FAST_SPIN_THRESHOLD,
        )
        if move.steps:
            # Choose the destination member of each crossed altitude FIRST (one
            # random draw per step — content-pipeline §11.1), then resolve the
            # morph from the prior clip to it, so the footage matches what we land
            # on. The currently-shown clip threads in as `from_clip_id`.
            src = req.from_clip_id or scale_at(app.state.ring, move.from_index).clip_id
            picks = tuple(random.random() for _ in move.steps)
            resolved = resolve_move(app.state.ring, move, src, picks)
        else:
            # A no-op move (delta=0) is the initial / re-roll pick: a fresh random
            # member of the current scale, no morph.
            landed = scale_at(app.state.ring, move.to_index)
            resolved = ResolvedMove(steps=(), target_clip_id=pick_clip_id(landed, random.random()))
        return resolved_move_to_dict(move, resolved)

    @app.post("/api/author/track")
    def api_author_track(req: AuthorTrackRequest):
        # Author mode (§11.5): run the classical optical-flow tracker on a clip's
        # base footage, returning the keyframed track geometry for the author to
        # accept/correct. Semantics (key/strings/tiers) stay the author's.
        clip = next((c for c in app.state.clips if c.id == req.clip_id), None)
        if clip is None:
            raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
        base = MEDIA_DIR / clip.base_file
        if not base.exists():
            raise HTTPException(status_code=404, detail=f"media absent: {clip.base_file}")
        try:
            import cv2
        except ImportError:
            raise HTTPException(status_code=503, detail="opencv-python not installed")
        from tools.pipeline.track import track_seed

        cap = cv2.VideoCapture(str(base))
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
        cap.release()
        seed_frame = max(0, min(total - 1, round(req.seed_t * total))) if total else 0
        return track_seed(
            base, req.key, tuple(req.seed_box), seed_frame=seed_frame,
            salience=req.salience, n_keyframes=req.n_keyframes, max_frames=req.max_frames,
        )

    @app.post("/api/author/clip")
    def api_author_clip(req: AuthorClipRequest):
        # Author mode (§11.5 / stage 6): persist authored labels/affect/strings for
        # an EXISTING pool clip — idempotent upsert that keeps the clip's media +
        # provenance and replaces only the authored content. Reloads in place so
        # the preview reflects the edit without a restart.
        from tools.pipeline.manifest import upsert_clip

        path = app.state.manifest_path
        data = json.loads(path.read_text())
        existing = next((c for c in data.get("clips", []) if c["id"] == req.clip_id), None)
        if existing is None:
            raise HTTPException(status_code=404, detail=f"unknown clip {req.clip_id!r}")
        merged = dict(existing)
        merged["annotations"] = req.annotations
        merged["affect"] = req.affect
        if req.strings:
            merged["strings"] = req.strings
        data = upsert_clip(data, merged)
        path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
        app.state.clips = load_manifest(path)
        app.state.ring = load_ring(path)
        return {"ok": True, "clip": merged}

    @app.middleware("http")
    async def _cache_policy(request, call_next):
        # Dev preview server: never let a browser serve a stale app.js/style.css —
        # by-eye iteration needs a plain refresh to always get the latest assets.
        # Media uses `no-cache` (store, but REVALIDATE every load): instant altitude
        # swaps already come from the in-memory blob preload (static/app.js), so the
        # HTTP cache only matters on (re)load — where a conditional request returns a
        # cheap 304 when unchanged, yet picks up a re-sourced/re-cropped clip
        # immediately. `immutable` was wrong here: it pinned stale footage for a year
        # so edited clips never showed without a manual cache clear.
        response = await call_next(request)
        if request.url.path.startswith("/media/"):
            response.headers["Cache-Control"] = "no-cache"
        else:
            response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
        return response

    if MEDIA_DIR.exists():
        app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
    if STATIC_DIR.exists():
        app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

    return app


app = create_app()
