"""/sources (#29) — fetch a URL's audio as a worker job.

The worker is the ONLY process that shells out to **yt-dlp**: it downloads the
best audio stream of a URL, transcodes to 44.1 kHz WAV (analysis-ready for
/separate, /loops, the feature stack), content-addresses it, stores it as a
`source` artifact (#27), and aliases the URL → content_id so a repeat fetch is
free (the submit path turns that into an idempotent born-done job).

URL scope is OPEN — any http(s) URL yt-dlp supports — gated upstream by the
platform apikey + nechapi monitoring (trusted-tenant model). We still hard-block
the SSRF footguns here: non-http(s) schemes, localhost, and RFC1918/link-local/
reserved IP literals. (DNS-rebinding to an internal name is out of scope for the
MVP; the apikey gate carries that risk.)

Heavy deps (yt-dlp, ffmpeg) load only in the worker. `_fetch_audio_wav` is a thin
seam so tests monkeypatch the network call with a synthetic WAV.
"""
from __future__ import annotations

import ipaddress
import subprocess
from pathlib import Path
from urllib.parse import urlparse, urlunparse

import artifacts
import cache
import config
import jobs


class SourceError(ValueError):
    """A caller-fixable problem (bad/blocked URL) — surfaced as 422 at submit."""


# ── URL validation / normalization ──────────────────────────────────────────────
def normalize_url(raw: str) -> str:
    """Validate + canonicalize a fetch URL. Raises SourceError on a rejected URL.
    The canonical form is the alias key, so trivially-different URLs to the same
    resource still dedupe (lowercased host, no fragment)."""
    raw = (raw or "").strip()
    if not raw:
        raise SourceError("empty url")
    u = urlparse(raw)
    if u.scheme not in ("http", "https"):
        raise SourceError(f"unsupported scheme '{u.scheme}' (only http/https)")
    host = (u.hostname or "").lower()
    if not host:
        raise SourceError("url has no host")
    if host in ("localhost", "localhost.localdomain") or host.endswith(".localhost"):
        raise SourceError("refusing localhost")
    try:
        ip = ipaddress.ip_address(host)                 # host is a literal IP?
    except ValueError:
        ip = None
    if ip is not None and (ip.is_private or ip.is_loopback or ip.is_link_local
                           or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
        raise SourceError(f"refusing non-public address {host}")
    # canonical: drop fragment, lowercase host, keep path+query (e.g. ?v=…)
    return urlunparse((u.scheme, host + (f":{u.port}" if u.port else ""),
                       u.path, u.params, u.query, ""))


# ── the fetch seam (monkeypatched in tests) ──────────────────────────────────────
def _fetch_audio_wav(url: str, outdir: str) -> Path:
    """Download best audio → 44.1k WAV via yt-dlp+ffmpeg. Returns the WAV path."""
    out_tmpl = str(Path(outdir) / "src.%(ext)s")
    cmd = [
        config.YTDLP_BIN, "--quiet", "--no-playlist", "--no-progress",
        "-f", "bestaudio/best",
        "-x", "--audio-format", "wav",
        "--postprocessor-args", f"ffmpeg:-ar {config.FETCH_SR}",
        "--max-filesize", f"{config.FETCH_MAX_MB}M",
        "--socket-timeout", "30",
        "-o", out_tmpl, url,
    ]
    try:
        subprocess.run(cmd, check=True, timeout=config.FETCH_TIMEOUT_SEC,
                       capture_output=True, text=True)
    except FileNotFoundError as e:
        raise RuntimeError(f"yt-dlp not installed ({config.YTDLP_BIN})") from e
    except subprocess.TimeoutExpired as e:
        raise RuntimeError(f"fetch timed out after {config.FETCH_TIMEOUT_SEC}s") from e
    except subprocess.CalledProcessError as e:
        tail = (e.stderr or "").strip().splitlines()[-1:] or ["(no stderr)"]
        raise RuntimeError(f"yt-dlp failed: {tail[0]}") from e
    wavs = sorted(Path(outdir).glob("*.wav"))
    if not wavs:
        raise RuntimeError("yt-dlp produced no audio (no extractable stream?)")
    return wavs[0]


def _probe(wav: Path) -> dict:
    """duration / samplerate / channels / bytes of the produced WAV."""
    import soundfile as sf
    info = sf.info(str(wav))
    return {
        "duration_sec": round(info.frames / info.samplerate, 3) if info.samplerate else 0.0,
        "sample_rate": info.samplerate,
        "channels": info.channels,
        "bytes": wav.stat().st_size,
    }


# ── the worker handler ────────────────────────────────────────────────────────
@jobs.handler("sources")
def run_sources(job: dict, progress) -> dict:
    """Fetch → transcode → content-address → store → alias. Returns the source
    descriptor (also cached under the content_id so a repeat URL is born-done)."""
    import tempfile

    url = job["params"]["url"]
    progress(0.05)
    with tempfile.TemporaryDirectory(prefix="fourier-src-") as td:
        wav = _fetch_audio_wav(url, td)
        progress(0.7)
        cid = cache.content_id(wav)
        meta = _probe(wav)
        ref = artifacts.store(cid, "source.wav", wav, kind="source")

    result = {
        "content_id": cid,
        "artifact_ref": f"{cid}/source.wav",      # the GET /artifacts/{cid}/{name} key
        "url": url,
        **{k: v for k, v in meta.items() if k != "bytes"},
    }
    cache.put(cid, "source", result=result, result_ref=ref, bytes_=meta["bytes"])
    cache.put_alias(url, cid)
    artifacts.enforce_cap("source")               # keep the store under its cap
    progress(1.0)
    return result
