"""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
from engines import ears, feats
from engines import grade as grade_eng   # VENDORED copy of the Foundry grader (see engines/grade.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:
        scopes = request.headers.get(config.SCOPES_HEADER, "*")
        return auth.Principal(tenant, frozenset(scopes.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


def require_scope(scope: str | None):
    """Dependency factory: resolve the gateway identity and (if `scope` is given)
    assert it carries that scope or the '*' wildcard. scope=None means "any
    authenticated caller". 401 if no/unknown identity, 403 if scoped out."""
    def dep(request: Request) -> auth.Principal:
        principal = _principal(request)
        if principal is None:
            raise HTTPException(401, "no authenticated identity (gateway headers absent)")
        if scope is not None and not principal.has_scope(scope):
            raise HTTPException(403, f"caller lacks scope '{scope}'")
        return principal
    return dep


# ── 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)"},
            "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_scope(None))):
    """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_scope("emotion"))):
    """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_scope("features"))):
    """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_scope("samples"))):
    """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("/grade")
async def grade(response: Response, file: UploadFile = File(...), role: str = "",
                _p: auth.Principal = Depends(require_scope("grade"))):
    """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}


# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate")
async def separate(_p: auth.Principal = Depends(require_scope("separate"))):
    """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)
