"""Fourier — ParVagues Audio-Intelligence API.

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/fourier/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
from fastapi.responses import FileResponse
from pydantic import BaseModel

import artifacts
import auth
import cache
import config
import jobs as jobslib
import scopes as scopelib
from engines import ears, feats, signal, streaming
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)
from engines import sources as sources_eng  # registers the "sources" job handler (#29)

app = FastAPI(
    title="Nech.PL Audio Intelligence API",
    version="0.1.0",
    description="Stem-aware audio analysis — emotion, features, sample role, loop "
                "grading, onsets/tempo, waveform & spectrogram, loudness, naming. "
                "The `audio` API of the Nech.PL APIs platform, served at /audio/v1/...; "
                "the gateway does central auth + metering (Bearer token).",
    # 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(tags=["audio"])   # routes mounted at root; the gateway owns the /audio/v1 prefix.
                                 # tag => generated client class is AudioApi (not DefaultApi).

# ── 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)
    # Count by the ROUTE TEMPLATE, not the concrete path — else /jobs/{id} and
    # /artifacts/{id}/{name} would mint an unbounded Prometheus series per id.
    route = request.scope.get("route")
    path = getattr(route, "path_format", None) or request.url.path
    key = (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 FOURIER_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", operation_id="healthz")
def healthz():
    ok, hint = ears.available()
    return {
        "service": "nech-audio",
        "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)"},
            "sources": {"available": _ytdlp_ok(), "hint": "yt-dlp URL fetch → 44.1k WAV (worker job)"},
            "separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
        },
    }


def _ytdlp_ok() -> bool:
    """Is the yt-dlp binary on PATH? (The /sources worker needs it; the API box
    may not have it — healthz says so honestly.)"""
    import shutil
    return shutil.which(config.YTDLP_BIN) is not None


@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 fourier_up 1 if the audio API is responding",
        "# TYPE fourier_up gauge",
        "fourier_up 1",
        "# HELP fourier_requests_total HTTP requests by path and status",
        "# TYPE fourier_requests_total counter",
    ]
    for (path, status), n in sorted(_REQ_COUNTS.items()):
        safe = path.replace("\\", "\\\\").replace('"', '\\"')
        lines.append(f'fourier_requests_total{{path="{safe}",status="{status}"}} {n}')
    try:
        st = cache.stats()   # one row per kind: {kind, n, bytes, hits}
        lines += [
            "# HELP fourier_cache_rows cached results by kind",
            "# TYPE fourier_cache_rows gauge",
            "# HELP fourier_cache_hits_total cache hits served by kind",
            "# TYPE fourier_cache_hits_total counter",
        ]
        for r in st:
            k = str(r.get("kind", "")).replace('"', '\\"')
            lines.append(f'fourier_cache_rows{{kind="{k}"}} {r.get("n", 0)}')
            lines.append(f'fourier_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", operation_id="whoami")
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}


# ── async jobs (#25): heavy work runs out-of-band; poll/cancel here ─────────────
class JobStatus(BaseModel):
    """The status of an async job (separate/sources/loops). `result` is populated
    once `status == "done"`; `error` once `status == "error"`."""
    job_id: str
    type: str
    status: str                       # pending | running | done | error | cancelled
    progress: float                   # 0.0 … 1.0
    result: dict | None = None
    result_ref: str | None = None
    error: str | None = None
    created: float
    started: float | None = None
    finished: float | None = None


@v1.get("/jobs/{job_id}", operation_id="getJob", response_model=JobStatus)
def get_job(job_id: str, principal: auth.Principal = Depends(require_auth)):
    """Poll an async job. 404 if it doesn't exist OR belongs to another tenant
    (we don't leak job existence across accounts)."""
    job = jobslib.get(job_id, account=principal.account)
    if job is None:
        raise HTTPException(404, "job not found")
    return job


@v1.delete("/jobs/{job_id}", operation_id="cancelJob", response_model=JobStatus)
def cancel_job(job_id: str, principal: auth.Principal = Depends(require_auth)):
    """Cancel a pending/running job (cooperative — a running handler stops at its
    next progress checkpoint). Idempotent: cancelling a finished job is a no-op."""
    status = jobslib.cancel(job_id, account=principal.account)
    if status is None:
        raise HTTPException(404, "job not found")
    return jobslib.get(job_id, account=principal.account)


# ── /sources (#29): fetch a URL's audio as a job; idempotent on repeat ──────────
class SourceRequest(BaseModel):
    """Submit a URL for audio fetch. `url` is any http(s) URL yt-dlp supports."""
    url: str
    priority: int = 0
    webhook_url: str | None = None


@v1.post("/sources", operation_id="submitSource", status_code=202, response_model=JobStatus)
def submit_source(req: SourceRequest, principal: auth.Principal = Depends(require)):
    """Fetch a URL's best audio → 44.1k WAV, as an async job. Returns `202` with a
    `job_id`; poll `GET /jobs/{id}` for the source descriptor (content_id +
    artifact_ref). A URL we've already fetched (and whose artifact still exists)
    comes back as a born-`done` job immediately — no re-download."""
    try:
        url = sources_eng.normalize_url(req.url)
    except sources_eng.SourceError as e:
        raise HTTPException(422, f"bad source url: {e}")

    cid = cache.resolve_alias(url)                     # seen this URL before?
    if cid:
        hit = cache.get(cid, "source")
        if hit and hit["result_ref"] and Path(hit["result_ref"]).exists():
            return jobslib.enqueue(principal.account, "sources", {"url": url},
                                   result={**hit["result"], "cached": True})
    return jobslib.enqueue(principal.account, "sources", {"url": url},
                           priority=req.priority, webhook_url=req.webhook_url)


# ── /artifacts (#27): serve a stored binary; nginx X-Accel-Redirect in prod ─────
@v1.get("/artifacts/{content_id}/{name}", operation_id="getArtifact")
def get_artifact(content_id: str, name: str,
                 _p: auth.Principal = Depends(require_auth)):
    """Stream a stored artifact (a fetched source WAV, later stems/loops). Any
    authenticated tenant holding the (opaque, content-addressed) ref may fetch it.
    In prod the app returns an `X-Accel-Redirect` and nginx streams the bytes
    (Range-capable, zero-copy); in dev it falls back to a direct FileResponse."""
    path = artifacts.resolve(content_id, name)
    if path is None:
        raise HTTPException(404, "artifact not found")
    if config.X_ACCEL:
        return Response(status_code=200, headers={
            "X-Accel-Redirect": artifacts.accel_path(content_id, name),
            "Content-Type": "application/octet-stream",
        })
    return FileResponse(path)


# ── shared analyze flow: upload → content-address → cache → compute (#26) ───────
# SAFETY (#7): the upload is STREAMED to a temp file in bounded chunks — never
# `await file.read()` into one big `bytes` (a 100 MB body would spike ~100 MB
# resident, and the content-hash + engines add their own copies). Peak memory
# stays O(chunk), so a large upload can't OOM the shared erable box. The size
# ceiling is enforced mid-stream (read at request time, so tests/ops can retune
# config.MAX_UPLOAD_MB live).
async def _spool(file: UploadFile) -> Path:
    limit = config.MAX_UPLOAD_MB * 1024 * 1024
    suffix = Path(file.filename or "clip").suffix or ".wav"
    tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
    path = Path(tmp.name)
    size = 0
    try:
        while chunk := await file.read(1 << 20):     # 1 MB at a time
            size += len(chunk)
            if size > limit:
                raise HTTPException(413, {
                    "error": "payload_too_large",
                    "detail": f"upload exceeds the {config.MAX_UPLOAD_MB} MB limit for this API",
                    "docs": "https://api.nech.pl/docs",
                })
            tmp.write(chunk)
        tmp.flush()
    except BaseException:
        tmp.close(); path.unlink(missing_ok=True); raise
    else:
        tmp.close()
    return path


def _source_meta(path: Path) -> dict:
    """Honest provenance for the response: the file's TRUE duration + size, so a
    caller always knows the full extent of what they sent — and, paired with an
    engine's own `duration_s` / `full_track`, whether it was analyzed whole or
    (for the clip-tier engines) only its head."""
    meta = {"size_bytes": path.stat().st_size}
    try:
        p = streaming.probe(str(path))
        meta.update(duration_s=round(p["duration_s"], 3), sr=p["sr"], channels=p["channels"])
    except Exception:
        pass
    return meta


def _cached(kind: str, params: dict, path: str, compute, response: Response):
    """Content-address the file, serve a cache hit instantly (X-Nech-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."""
    cid = cache.content_id(path)
    hit = cache.get(cid, kind, params)
    if hit:
        response.headers["X-Nech-Cache"] = "hit"
        return cid, hit["result"]
    try:
        result = compute(path)
    except Exception as e:
        raise HTTPException(422, f"{kind} analysis failed: {e}")
    cache.put(cid, kind, result=result, params=params)
    response.headers["X-Nech-Cache"] = "miss"
    return cid, result


async def _run(file: UploadFile, kind: str, params: dict, compute, response: Response):
    """Spool the upload (bounded), content-address + cache + compute, always
    cleaning up the temp file. Returns (content_id, result, source_meta)."""
    path = await _spool(file)
    try:
        source = _source_meta(path)
        cid, result = _cached(kind, params, str(path), compute, response)
        return cid, result, source
    finally:
        path.unlink(missing_ok=True)


@v1.post("/analyze/emotion", operation_id="analyzeEmotion")
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.
    Clip-tier: the emotion engine reads the first ~60 s (`source.duration_s`
    reports the full length)."""
    cid, result, source = await _run(file, "emotion", {"engine": config.EMOTION_ENGINE},
                                     ears.emotion_read, response)
    return {"filename": file.filename, "content_id": cid, "emotion": result, "source": source}


@v1.post("/features", operation_id="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.
    Clip-tier: analyses the first ~30 s (`source.duration_s` is the full length)."""
    cid, result, source = await _run(file, "features", {"engine": "light", "rhythm": rhythm},
                                     lambda p: feats.features(p, rhythm=rhythm), response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/analyze/samples", operation_id="analyzeSamples")
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. Clip-tier: first ~30 s."""
    cid, result, source = await _run(file, "samples", {"engine": "light", "name": name},
                                     lambda p: feats.sample_profile(p, name=name), response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/onsets", operation_id="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.
    Full-track: streams the WHOLE upload (`full_track:true`), not just a head clip."""
    cid, result, source = await _run(file, "onsets", {"v": 2}, signal.onsets, response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/waveform", operation_id="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.
    Full-track: bins span the WHOLE track via an exact streaming reduction."""
    cid, result, source = await _run(file, "waveform", {"bins": bins, "v": 2},
                                     lambda p: signal.waveform(p, bins=bins), response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/analyze", operation_id="analyzeAll")
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.
    Clip-tier composite: emotion ~60 s, features/sample ~30 s of the head."""
    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, source = await _run(file, "analyze", params, _compute, response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/grade", operation_id="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."""
    cid, result, source = await _run(file, "grade", {"role": role or None, "v": 1},
                                     lambda p: grade_eng.grade(p, role=role or None).model_dump(),
                                     response)
    return {"filename": file.filename, "content_id": cid, "grade": result, "source": source}


@v1.post("/loudness", operation_id="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.
    Full-track: peak/true-peak/RMS are exact; integrated LUFS is chunked-approximate
    (`approximate:true`) — pyloudnorm gating can't merge chunks exactly."""
    cid, result, source = await _run(file, "loudness", {"v": 2},
                                     loudness_eng.loudness, response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/spectrum", operation_id="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. Full-track: frames span
    the WHOLE track (streamed windows binned into the global time grid)."""
    params = {"bands": bands, "frames": frames, "mel": mel, "v": 2}
    cid, result, source = await _run(file, "spectrum", params,
                                     lambda p: signal.spectrum(p, bands=bands, frames=frames, mel=mel),
                                     response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


@v1.post("/naming", operation_id="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.
    Clip-tier: first ~30 s."""
    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, source = await _run(file, "naming", {"index": index, "v": 1}, _compute, response)
    return {"filename": file.filename, "content_id": cid, **result, "source": source}


# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate", operation_id="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)
