"""Douanier — ParVagues Audio-Intelligence API (the customs gate).

Walking skeleton (#22 scaffold + #36 vertical): a versioned FastAPI app with a
health check and ONE real, synchronous endpoint — /v1/analyze/emotion — guarded
by a hardcoded dev bearer. It proves the three risky seams end-to-end (engines
import · service runs · a client can auth & call) before we invest in the
SQLite auth/quota/jobs/cache machinery (#23–#27) that hardens it later.

  run:  cd armada/api && ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
"""
import secrets
import tempfile
from pathlib import Path

from fastapi import FastAPI, APIRouter, UploadFile, File, Request, HTTPException, Depends, Response
from fastapi.middleware.cors import CORSMiddleware

import auth
import cache
import config
import scopes as scopelib
from engines import ears, feats, signal
from engines import loudness as loudness_eng
from engines import grade as grade_eng    # VENDORED copy of the Foundry grader (see engines/grade.py)
from engines import naming as naming_eng  # VENDORED copy of the Foundry namer (see engines/naming.py)

app = FastAPI(
    title="Douanier — the `audio` sub-API of the nech.pl platform",
    version="0.1.0",
    description="Customs gate over the fleet's audio-intelligence engines. Served "
                "publicly at /audio/v1/...; the platform gateway does central auth + "
                "metering and injects X-Tenant/X-Scopes. See SRE.md + DEPLOY.md.",
    # internal paths (gateway strips /audio/v1 and proxies here at root); root_path
    # makes /openapi.json + docs advertise the PUBLIC /audio/v1/... paths to clients.
    root_path=config.ROOT_PATH,
    docs_url="/docs",
    openapi_url="/openapi.json",
)

# hexa lives in Shipow's Vercel world → allow cross-origin calls. Tighten to his
# origins once known (kept open in dev so the walking skeleton is easy to hit).
app.add_middleware(
    CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)

v1 = APIRouter()   # routes mounted at root; the gateway owns the /audio/v1 prefix.

# ── observability (#37) — a counter the erable Prometheus scrapes ───────────────
_REQ_COUNTS: dict[tuple[str, int], int] = {}


@app.middleware("http")
async def _meter(request: Request, call_next):
    resp = await call_next(request)
    key = (request.url.path, resp.status_code)
    _REQ_COUNTS[key] = _REQ_COUNTS.get(key, 0) + 1
    return resp


# ── auth — TRUST the platform gateway's injected identity (supersedes #23/#24) ──
def _principal(request: Request) -> auth.Principal | None:
    """Resolve the caller from the gateway-injected headers. The platform's
    `_platform` service authenticates the bearer at the edge (nginx auth_request)
    and forwards X-Tenant / X-Scopes; we're loopback-only, so only the gateway
    can reach us and those headers are trustworthy. No token store of our own.

    Local-dev fallback (no gateway in front): a bearer matching DOUANIER_DEV_TOKEN
    grants a wildcard, so `uvicorn` + curl works without the platform."""
    tenant = request.headers.get(config.TENANT_HEADER)
    if tenant:
        granted = request.headers.get(config.SCOPES_HEADER, "")
        return auth.Principal(tenant, frozenset(granted.split()), None, None)
    authz = request.headers.get("authorization", "")
    kind, _, token = authz.partition(" ")
    if (kind.lower() == "bearer" and token and config.DEV_TOKEN
            and secrets.compare_digest(token, config.DEV_TOKEN)):
        return auth.Principal("dev", frozenset("*"), None, None)
    return None


# Our domain in the platform scope tree, from root_path "/audio/v1" → "audio".
_DOMAIN = (config.ROOT_PATH.strip("/").split("/") or ["audio"])[0] or "audio"


def _required_scope(request: Request) -> str:
    """The platform scope this request needs, derived from its path — so access
    control tracks the URL by construction (see scopes.py). The gateway already
    stripped /audio/v1, so request.url.path is the route, e.g. /analyze/emotion
    → api:audio:analyze:emotion."""
    return scopelib.required_for(_DOMAIN, request.url.path)


def require(request: Request) -> auth.Principal:
    """Path-derived scope gate: resolve the gateway identity and assert it carries
    a scope authorizing THIS route. 401 if no identity, 403 if scoped out. The
    platform gateway enforces the same rule centrally; this is defense-in-depth."""
    principal = _principal(request)
    if principal is None:
        raise HTTPException(401, "no authenticated identity (gateway headers absent)")
    required = _required_scope(request)
    if not principal.has_scope(required):
        raise HTTPException(403, f"caller lacks scope '{required}'")
    return principal


def require_auth(request: Request) -> auth.Principal:
    """Any authenticated caller (no specific scope) — for identity echo endpoints."""
    principal = _principal(request)
    if principal is None:
        raise HTTPException(401, "no authenticated identity (gateway headers absent)")
    return principal


# ── health ──────────────────────────────────────────────────────────────────
@v1.get("/healthz")
def healthz():
    ok, hint = ears.available()
    return {
        "service": config.SERVICE,
        "api": "audio",
        "version": config.VERSION,
        "status": "ok",
        "engines": {
            "emotion": {"selected": config.EMOTION_ENGINE, "available": ok, "hint": hint},
            "features": {"available": ok, "hint": "light feature stack (librosa, no torch)"},
            "samples": {"available": ok, "hint": "measured sample EDA + role (librosa, no torch)"},
            "grade": {"available": True, "hint": "Foundry mechanical loop grader (no torch)"},
            "onsets": {"available": ok, "hint": "onset times + tempo (librosa, no torch)"},
            "waveform": {"available": ok, "hint": "render-ready peak/RMS envelope (no torch)"},
            "analyze": {"available": ok, "hint": "composite emotion+features+sample in one call"},
            "loudness": {"available": True, "hint": "BS.1770 LUFS + peak/true-peak (pyloudnorm, no torch)"},
            "spectrum": {"available": ok, "hint": "FFT-as-a-service: downsampled spectrogram (no torch)"},
            "naming": {"available": ok, "hint": "convention-compliant sample namer (no torch)"},
            "separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
        },
    }


@app.get("/metrics", include_in_schema=False)
def metrics():
    """Prometheus text exposition (the erable scraper hits this). Counts only —
    no secrets. Unauthenticated by design, reachable only on loopback/gateway."""
    lines = [
        "# HELP douanier_up 1 if the audio API is responding",
        "# TYPE douanier_up gauge",
        "douanier_up 1",
        "# HELP douanier_requests_total HTTP requests by path and status",
        "# TYPE douanier_requests_total counter",
    ]
    for (path, status), n in sorted(_REQ_COUNTS.items()):
        safe = path.replace("\\", "\\\\").replace('"', '\\"')
        lines.append(f'douanier_requests_total{{path="{safe}",status="{status}"}} {n}')
    try:
        st = cache.stats()   # one row per kind: {kind, n, bytes, hits}
        lines += [
            "# HELP douanier_cache_rows cached results by kind",
            "# TYPE douanier_cache_rows gauge",
            "# HELP douanier_cache_hits_total cache hits served by kind",
            "# TYPE douanier_cache_hits_total counter",
        ]
        for r in st:
            k = str(r.get("kind", "")).replace('"', '\\"')
            lines.append(f'douanier_cache_rows{{kind="{k}"}} {r.get("n", 0)}')
            lines.append(f'douanier_cache_hits_total{{kind="{k}"}} {r.get("hits", 0)}')
    except Exception:
        pass
    return Response("\n".join(lines) + "\n", media_type="text/plain; version=0.0.4")


@v1.get("/me")
def whoami(principal: auth.Principal = Depends(require_auth)):
    """Echo the caller's gateway-resolved identity — handy for onboarding smoke tests."""
    return {"account": principal.account, "scopes": sorted(principal.scopes),
            "expires_at": principal.expires_at}


# ── shared analyze flow: upload → content-address → cache → compute (#26) ───────
async def _read_upload(file: UploadFile) -> bytes:
    data = await file.read()
    limit = config.MAX_UPLOAD_MB * 1024 * 1024
    if len(data) > limit:
        raise HTTPException(413, f"file too large (> {config.MAX_UPLOAD_MB} MB)")
    return data


def _cached(kind: str, params: dict, data: bytes, filename: str, compute, response: Response):
    """Content-address the bytes, serve a cache hit instantly (X-Douanier-Cache:
    hit), else run `compute(path)` and cache it. Returns (content_id, result).
    The same engine call is paid ONCE per (content_id, params) — the margin lever."""
    suffix = Path(filename or "clip").suffix or ".wav"
    with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
        tmp.write(data)
        tmp.flush()
        cid = cache.content_id(tmp.name)
        hit = cache.get(cid, kind, params)
        if hit:
            response.headers["X-Douanier-Cache"] = "hit"
            return cid, hit["result"]
        try:
            result = compute(tmp.name)
        except Exception as e:
            raise HTTPException(422, f"{kind} analysis failed: {e}")
    cache.put(cid, kind, result=result, params=params)
    response.headers["X-Douanier-Cache"] = "miss"
    return cid, result


@v1.post("/analyze/emotion")
async def analyze_emotion(response: Response, file: UploadFile = File(...),
                          _p: auth.Principal = Depends(require)):
    """Upload a short audio clip → valence/arousal + top emotions. Cached (#26):
    the same audio returns instantly on a repeat instead of recomputing."""
    data = await _read_upload(file)
    params = {"engine": config.EMOTION_ENGINE}
    cid, result = _cached("emotion", params, data, file.filename, ears.emotion_read, response)
    return {"filename": file.filename, "content_id": cid, "emotion": result}


@v1.post("/features")
async def features(response: Response, file: UploadFile = File(...), rhythm: bool = True,
                   _p: auth.Principal = Depends(require)):
    """Upload a clip → the ~35-dim audio feature stack (spectral moments, MFCCs,
    chroma/key, envelope, + rhythm when `rhythm=true`). Torch-free, cached."""
    data = await _read_upload(file)
    params = {"engine": "light", "rhythm": rhythm}
    cid, result = _cached("features", params, data, file.filename,
                          lambda p: feats.features(p, rhythm=rhythm), response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/analyze/samples")
async def analyze_samples(response: Response, file: UploadFile = File(...), name: str = "",
                          _p: auth.Principal = Depends(require)):
    """Upload a one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|
    tops|atmos) from the spectrum, never the name. Optional `name` only
    disambiguates breaks/drums. Torch-free, cached."""
    data = await _read_upload(file)
    params = {"engine": "light", "name": name}
    cid, result = _cached("samples", params, data, file.filename,
                          lambda p: feats.sample_profile(p, name=name), response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/onsets")
async def onsets(response: Response, file: UploadFile = File(...),
                 _p: auth.Principal = Depends(require)):
    """Upload audio → onset hit times (s) + tempo + onset rate. The cheapest tier
    (no model): rhythmic hits for visual sync / slicing. Torch-free, cached."""
    data = await _read_upload(file)
    cid, result = _cached("onsets", {"v": 1}, data, file.filename, signal.onsets, response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/waveform")
async def waveform(response: Response, file: UploadFile = File(...), bins: int = 800,
                   _p: auth.Principal = Depends(require)):
    """Upload audio → render-ready waveform: per-bin [min,max] + a 0..1 RMS energy
    envelope (`?bins=`, ≤4000). For audio-reactive visuals. Torch-free, cached."""
    data = await _read_upload(file)
    cid, result = _cached("waveform", {"bins": bins, "v": 1}, data, file.filename,
                          lambda p: signal.waveform(p, bins=bins), response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/analyze")
async def analyze_all(response: Response, file: UploadFile = File(...),
                      _p: auth.Principal = Depends(require)):
    """One call → emotion + features + sample role for a clip (fewer round-trips
    for hexa). Each is the same engine the dedicated routes use. Cached as a unit."""
    data = await _read_upload(file)
    params = {"engine": config.EMOTION_ENGINE, "v": 1}

    def _compute(p):
        return {"emotion": ears.emotion_read(p),
                "features": feats.features(p, rhythm=True)["features"],
                "sample": feats.sample_profile(p)}
    cid, result = _cached("analyze", params, data, file.filename, _compute, response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/grade")
async def grade(response: Response, file: UploadFile = File(...), role: str = "",
                _p: auth.Principal = Depends(require)):
    """Upload a loop/one-shot → the Foundry's MECHANICAL quality grade: composite
    0..1 + S/A/B/C/D tier, per-rule sub-scores (seam click, zero-crossing
    cleanliness, DC, bar self-consistency, level, bass mono-compat) + flags.
    `role` ('bass'|'drums'|…) is a hint. WAV/FLAC preferred. Torch-free, cached."""
    data = await _read_upload(file)
    params = {"role": role or None, "v": 1}
    cid, result = _cached("grade", params, data, file.filename,
                          lambda p: grade_eng.grade(p, role=role or None).model_dump(), response)
    return {"filename": file.filename, "content_id": cid, "grade": result}


@v1.post("/loudness")
async def loudness(response: Response, file: UploadFile = File(...),
                   _p: auth.Principal = Depends(require)):
    """Upload audio → BS.1770 integrated LUFS + sample/true peak + crest + the
    gain (dB) to hit each delivery target (-14 streaming, -9 club). Torch-free, cached."""
    data = await _read_upload(file)
    cid, result = _cached("loudness", {"v": 1}, data, file.filename,
                          loudness_eng.loudness, response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/spectrum")
async def spectrum(response: Response, file: UploadFile = File(...),
                   bands: int = 64, frames: int = 200, mel: bool = True,
                   _p: auth.Principal = Depends(require)):
    """FFT-as-a-service: a downsampled, render-ready spectrogram (`bands`×`frames`,
    0..1). `mel=` perceptual vs log-linear; `frames=1` → a single averaged FFT
    spectrum. Bounded payload → caches cheaply. Torch-free."""
    data = await _read_upload(file)
    params = {"bands": bands, "frames": frames, "mel": mel, "v": 1}
    cid, result = _cached("spectrum", params, data, file.filename,
                          lambda p: signal.spectrum(p, bands=bands, frames=frames, mel=mel),
                          response)
    return {"filename": file.filename, "content_id": cid, **result}


@v1.post("/naming")
async def naming(response: Response, file: UploadFile = File(...), index: int = 1,
                 _p: auth.Principal = Depends(require)):
    """Upload a sample → a convention-compliant name (NN_role_character) derived
    from the MEASURED role + character, not the file name. Torch-free, cached."""
    data = await _read_upload(file)

    def _compute(p):
        prof = feats.sample_profile(p)
        ff = feats.features(p, rhythm=False)["features"]
        character = feats.character_of(ff, prof)
        suggested = naming_eng.suggest_name(index, prof["role"], character=character)
        return {"suggested_name": suggested, "role": prof["role"], "character": character,
                "stem": naming_eng.stem_of(prof["role"]), "lint": naming_eng.lint(suggested)}
    cid, result = _cached("naming", {"index": index, "v": 1}, data, file.filename, _compute, response)
    return {"filename": file.filename, "content_id": cid, **result}


# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate")
async def separate(_p: auth.Principal = Depends(require)):
    """Stem separation (demucs/roformer). erable has no GPU and no CPU-viable path,
    so this is a deliberate 503 until a GPU-runner backend is wired (env-selected,
    same response shape). The route exists now so hexa can code against it and get
    a clean, documented "compute pending" rather than a 404."""
    raise HTTPException(503, detail={
        "error": "compute_unavailable",
        "detail": "stem separation needs a GPU runner; not available on this host yet.",
        "retriable": True,
    })


app.include_router(v1)
