"""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

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},
            "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}


# ── #36 walking-skeleton endpoint: synchronous emotion read ─────────────────────
@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 (CLAP).

    Content-addressed cache (#26): the same audio returns instantly on a repeat
    (X-Douanier-Cache: hit) instead of paying the ~CLAP compute again. Still
    synchronous on a miss; #28 layers the async job queue under this.
    """
    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)")
    suffix = Path(file.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)
        params = {"engine": config.EMOTION_ENGINE}
        hit = cache.get(cid, "emotion", params)
        if hit:
            response.headers["X-Douanier-Cache"] = "hit"
            return {"filename": file.filename, "content_id": cid, "emotion": hit["result"]}
        try:
            read = ears.emotion_read(tmp.name)
        except Exception as e:
            raise HTTPException(422, f"emotion analysis failed: {e}")
    cache.put(cid, "emotion", result=read, params=params)
    response.headers["X-Douanier-Cache"] = "miss"
    return {"filename": file.filename, "content_id": cid, "emotion": read}


# ── #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)
