"""Acceptance-test harness for the ParVagues rig.

Why this exists (2026-07-28, J-7 to OPAL)
-----------------------------------------
Unit tests on pure functions prove nothing about whether the rig makes sound.
Every failure that has actually cost this project an evening lived in the SEAM:
a helper that typechecks but silences an orbit, a knob that moves but changes
nothing, a sample folder whose index is one short, an orbit still playing from
the previous track. None of those are visible to pytest, and all of them are
measurable.

So this harness drives the REAL rig — boots a track through tidal-remote, taps
the real PipeWire graph through probe-chain's `Tap`, and asserts on measured
audio. It is deliberately reusable: a case is a small object with a name and an
assertion, not a one-shot script, so tonight's investigation becomes tomorrow's
regression suite.

The right lens per control kind
-------------------------------
An assertion is only as good as the quantity it measures, and the single most
expensive lesson of this week is that rms is BLIND to a filter or a crusher:
they change the spectrum, not the level. So:

    gain / mute      -> rms dBFS
    filter / crush   -> spectral centroid
    ply / mask       -> onset density
    fade vs sparse   -> the TIME axis, never an aggregate

Safety
------
Nothing here sends CC. LED work goes through the mock surface (see mock_lcxl),
never the live device, and no case ever touches CC 77-84.
"""

from __future__ import annotations

import importlib.util
import math
import subprocess
import sys
import tempfile
import time
import wave
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np

TOOLS = Path(__file__).resolve().parent.parent
REPO = TOOLS.parent


def _load(name: str, path: Path):
    """Import a hyphenated tool file as a module (filenames aren't identifiers)."""
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[name] = mod
    spec.loader.exec_module(mod)
    return mod


probe = _load("probe_chain", TOOLS / "probe-chain.py")


# --------------------------------------------------------------------------
# Rig readiness — a failing AT must distinguish "broken" from "not running"
# --------------------------------------------------------------------------

@dataclass
class RigState:
    sclang: int = 0
    scsynth: int = 0
    ardour: int = 0
    sc_ports: int = 0

    @property
    def ready(self) -> bool:
        return self.scsynth >= 1 and self.sc_ports > 0

    def why_not(self) -> str:
        if self.scsynth < 1:
            return "scsynth is not running"
        if self.sc_ports == 0:
            return "SuperCollider has no output ports"
        return ""


def rig_state() -> RigState:
    def count(pattern: str) -> int:
        try:
            out = subprocess.run(["pgrep", "-fc", pattern], capture_output=True,
                                 text=True, timeout=5).stdout.strip()
            return int(out or 0)
        except Exception:
            return 0

    ports = probe.existing_ports()
    return RigState(
        sclang=count("sclang"),
        scsynth=count("scsynth"),
        ardour=count("ardour-[0-9]"),
        sc_ports=sum(1 for p in ports if p.startswith(f"{probe.SC_NODE}:out_")),
    )


# --------------------------------------------------------------------------
# Measurement
# --------------------------------------------------------------------------

@dataclass
class Shape:
    """What one orbit's audio looked like over one capture window."""

    orbit: int
    peak_db: float = -math.inf
    rms_db: float = -math.inf
    centroid_hz: float = 0.0
    low_ratio: float = 0.0     # energy below 300 Hz, as a fraction of total
    high_ratio: float = 0.0    # energy above 1 kHz, as a fraction of total
    onsets_per_s: float = 0.0
    duty: float = 0.0          # fraction of bins within 25 dB of the loudest
    timeline: list[float] = field(default_factory=list)

    @property
    def crest_db(self) -> float:
        if self.peak_db == -math.inf or self.rms_db == -math.inf:
            return 0.0
        return self.peak_db - self.rms_db

    @property
    def silent(self) -> bool:
        return self.peak_db < -60.0

    def __str__(self) -> str:
        return (f"d{self.orbit}: peak {self.peak_db:6.1f} rms {self.rms_db:6.1f} "
                f"crest {self.crest_db:5.1f} centroid {self.centroid_hz:7.1f}Hz "
                f"low {self.low_ratio*100:4.1f}% high {self.high_ratio*100:4.1f}% "
                f"onsets {self.onsets_per_s:5.2f}/s duty {self.duty*100:5.1f}%")


def _read(path: Path) -> tuple[np.ndarray, int]:
    with wave.open(str(path)) as w:
        sr, n, ch = w.getframerate(), w.getnframes(), w.getnchannels()
        a = np.frombuffer(w.readframes(n), dtype=np.int16).astype(np.float32) / 32768
    if ch > 1:
        a = a[: (len(a) // ch) * ch].reshape(-1, ch).mean(1)
    return a, sr


def _db(x: float) -> float:
    return 20 * math.log10(x) if x > 1e-9 else -math.inf


def spectral_centroid(sig: np.ndarray, sr: int) -> float:
    """Brightness. The ONLY lens that sees a filter or a crusher.

    Computed over frames that actually contain signal — averaging silence into
    the centroid drags every reading toward the same meaningless number.
    """
    n = 2048
    hop = 1024
    if len(sig) < n:
        return 0.0
    freqs = np.fft.rfftfreq(n, 1.0 / sr)
    win = np.hanning(n)
    vals, weights = [], []
    for i in range(0, len(sig) - n, hop):
        frame = sig[i:i + n]
        rms = float(np.sqrt((frame ** 2).mean()))
        if rms < 1e-4:
            continue
        mag = np.abs(np.fft.rfft(frame * win))
        s = mag.sum()
        if s <= 0:
            continue
        vals.append(float((freqs * mag).sum() / s))
        weights.append(rms)
    if not vals:
        return 0.0
    return float(np.average(vals, weights=weights))


def band_ratio(sig: np.ndarray, sr: int, f_lo: float, f_hi: float) -> float:
    """Fraction of total energy inside [f_lo, f_hi).

    Why this exists, and why the centroid is NOT used for register claims:
    the first version of `sub_is_low` asserted on the spectral centroid and
    failed d3 at 837 Hz — an orbit carrying `# lpf 220`. A magnitude-weighted
    centroid integrates the whole spectrum, so a low-level broadband noise floor
    spread across 20 kHz drags the mean far above where the actual ENERGY sits.
    d5 showed the same distortion: `lpf 1400`, centroid 6344 Hz.

    The centroid is still the right lens for a RELATIVE test — `filter_bites`
    moved 2731 -> 2164 Hz cleanly, because the noise floor is common to both
    readings and cancels. It is the wrong lens for an ABSOLUTE one. Band-energy
    ratio answers "where does the energy live" directly and is unbothered by a
    floor that carries almost none of it.
    """
    n, hop = 4096, 2048
    if len(sig) < n:
        return 0.0
    freqs = np.fft.rfftfreq(n, 1.0 / sr)
    sel = (freqs >= f_lo) & (freqs < f_hi)
    win = np.hanning(n)
    inside = total = 0.0
    for i in range(0, len(sig) - n, hop):
        frame = sig[i:i + n]
        if float(np.sqrt((frame ** 2).mean())) < 1e-4:
            continue
        power = np.abs(np.fft.rfft(frame * win)) ** 2
        inside += float(power[sel].sum())
        total += float(power.sum())
    return inside / total if total > 0 else 0.0


def onset_rate(sig: np.ndarray, sr: int) -> float:
    """Onsets per second via spectral flux.

    Envelope-ratio detection returns a flat 0.0 on a sustained bass, and a zero
    baseline turns any comparison into a division by zero that reads as "+inf %,
    EFFECTIVE". Flux does not have that failure mode.
    """
    n, hop = 1024, 512
    if len(sig) < n * 4:
        return 0.0
    win = np.hanning(n)
    prev = None
    flux = []
    for i in range(0, len(sig) - n, hop):
        mag = np.abs(np.fft.rfft(sig[i:i + n] * win))
        if prev is not None:
            flux.append(float(np.sum(np.maximum(mag - prev, 0))))
        prev = mag
    if not flux:
        return 0.0
    f = np.array(flux)
    med = np.median(f)
    mad = np.median(np.abs(f - med)) or 1e-9
    thresh = med + 2.5 * mad
    refractory = int(0.05 * sr / hop)
    count, last = 0, -999
    for i, v in enumerate(f):
        if v > thresh and i - last > refractory:
            count += 1
            last = i
    return count / (len(sig) / sr)


def measure(orbits: list[int], seconds: float = 8.0,
            bin_s: float = 0.5) -> dict[int, Shape]:
    """Tap the live graph and describe each orbit with every lens at once.

    One capture, all lenses: a second capture of "the same" state is not the
    same state (patterns vary per cycle), so measuring gain and brightness in
    separate runs invites a comparison between two different moments.
    """
    tmp = Path(tempfile.mkdtemp(prefix="pv-at-"))
    taps = {dn: probe.Tap(f"d{dn}_sc", probe.sc_ports_for(dn), tmp) for dn in orbits}

    try:
        for dn, tap in taps.items():
            tap.start(f"at_d{dn}")
        time.sleep(1.2)
        for dn, tap in taps.items():
            tap.link(f"at_d{dn}")
        time.sleep(seconds)
        contaminated = {dn for dn, tap in taps.items() if not tap.verify()[0]}
    finally:
        for tap in taps.values():
            tap.stop()

    out: dict[int, Shape] = {}
    for dn, tap in taps.items():
        sh = Shape(orbit=dn)
        if dn in contaminated:
            # probe-chain's own lesson: a contaminated row must be DISCARDED,
            # not interpreted. Leaving it silent-looking would read as a failure.
            sh.peak_db = math.nan
            out[dn] = sh
            continue
        try:
            a, sr = _read(tap.path)
        except Exception:
            out[dn] = sh
            continue
        if len(a) == 0:
            out[dn] = sh
            continue
        sh.peak_db = _db(float(np.max(np.abs(a))))
        sh.rms_db = _db(float(np.sqrt((a ** 2).mean())))
        sh.centroid_hz = spectral_centroid(a, sr)
        sh.low_ratio = band_ratio(a, sr, 0.0, 300.0)
        sh.high_ratio = band_ratio(a, sr, 1000.0, sr / 2)
        sh.onsets_per_s = onset_rate(a, sr)
        hop = max(1, int(sr * bin_s))
        bins = [_db(float(np.sqrt((a[i:i + hop] ** 2).mean())))
                for i in range(0, len(a) - hop + 1, hop)]
        sh.timeline = bins
        live = [b for b in bins if b != -math.inf]
        if live:
            top = max(live)
            sh.duty = sum(1 for b in live if b > top - 25.0) / len(bins)
        out[dn] = sh
    return out


# --------------------------------------------------------------------------
# Driving the rig
# --------------------------------------------------------------------------

def remote(*args: str) -> bool:
    """Fire a tidal-remote command. Fire-and-forget: success is MEASURED, never claimed."""
    try:
        subprocess.run([sys.executable, str(TOOLS / "tidal-remote.py"), *args],
                       capture_output=True, timeout=30)
        return True
    except Exception:
        return False


def play(track: Path, settle: float = 6.0) -> None:
    remote("eval-file", str(track))
    time.sleep(settle)


def hush() -> None:
    remote("hush")
