"""Producer stem-pack batching: name parsing, label verification, and the kit audit.

The DSP-heavy paths are exercised end-to-end on real stems; what is locked here is the
logic that decides *what a stem is* and *whether a shipped loop is defensible* — the two
places a silent wrong answer would propagate into a kit nobody notices is bad.

    cd tools/foundry && python3 -m pytest tests/test_stempack.py -q
"""
import sys
from pathlib import Path

import numpy as np
import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from engine import kitcheck as K  # noqa: E402
from engine import roles as R  # noqa: E402

SR = 44100


# ── name parsing ─────────────────────────────────────────────────────────────
def test_parses_the_pack_scaffolding():
    n = R.parse_stem_name("MAREA MIX10 123BPM KIT STEM.wav")
    assert n.track == "MAREA"
    assert n.role_token == "kit"
    assert n.claimed_family == "drums"
    assert n.bpm_hint == 123.0


def test_multiword_roles_and_the_NEW_variant():
    assert R.parse_stem_name("ANGIE MIX18 PAD STUFF STEM.wav").role_token == "pad stuff"
    assert R.parse_stem_name("ANGIE NEW F VOX STEM.wav").claimed_family == "vox"
    assert R.parse_stem_name("BIG HEN MIX1 ALL DRUMS STEM.wav").claimed_family == "drums"


def test_isolated_hits_are_not_the_same_family_as_a_groove():
    """A KICK stem and an ALL DRUMS stem both measure percussive; only the name
    separates a one-shot source from loop material, so the map must keep them apart."""
    assert R.parse_stem_name("IAMAPARTY MIX1 KICK STEM.wav").claimed_family == "hits"
    assert R.parse_stem_name("IAMAPARTY MIX1 ALL DRUMS STEM.wav").claimed_family == "drums"


def test_unknown_token_falls_back_on_a_contained_word():
    """`MAKE IT THRU VOX` is track-specific, but it still says VOX."""
    n = R.parse_stem_name("MAREA MIX10 123BPM MAKE IT THRU VOX STEM.wav")
    assert n.claimed_family == "vox"


def test_a_wholly_unknown_token_is_soft_not_fatal():
    n = R.parse_stem_name("MAREA MIX10 123BPM MAREA STEM.wav")
    assert n.known is False
    assert n.claimed_family == "tonal"     # a default the probe is free to override


# ── the probe overrides the label ────────────────────────────────────────────
def _sine(f, secs=6.0, amp=0.3):
    t = np.arange(int(SR * secs)) / SR
    return (amp * np.sin(2 * np.pi * f * t)).astype(np.float32)


def test_a_sub_sine_measures_as_bass_whatever_the_name_says():
    p = R.probe(_sine(50), SR, claimed="tonal")
    assert p.measured_family == "bass"
    assert not p.agrees
    assert "measurement says bass" in p.note


def test_near_silence_is_not_usable():
    p = R.probe(np.zeros(SR * 5, dtype=np.float32), SR, claimed="drums")
    assert not p.usable


def test_vox_and_tonal_do_not_contradict_each_other():
    """The cheap features cannot separate a sung line from a rhodes chord, so a vox
    claim must not be overturned by a `tonal` measurement — that is CLAP's job."""
    p = R.probe(_sine(440), SR, claimed="vox")
    p.measured_family = "tonal"
    p.agrees = True
    assert R.effective_family(R.parse_stem_name("X MIX1 LV STEM.wav"), p) == "vox"


def test_a_hits_claim_survives_a_drums_measurement():
    p = R.probe(_sine(60), SR, claimed="hits")
    p.measured_family, p.agrees = "drums", False
    name = R.parse_stem_name("X MIX1 KICK STEM.wav")
    assert R.effective_family(name, p) == "hits"


# ── the kit audit ────────────────────────────────────────────────────────────
def _write(tmp, name, y, sr=SR):
    import soundfile as sf
    p = Path(tmp) / name
    sf.write(str(p), np.stack([y, y], axis=1), sr, subtype="PCM_24")
    return str(p)


def _cut(path, **kw):
    d = dict(kit="k", name=Path(path).stem, path=path, track="T", stem_role="kit",
             family="drums", mode="loop", bars=4, bpm=120.0, dur_s=0.0, start_s=0.0,
             finder_score=1.0, tier="A", grade=0.85, flags=[], tags={})
    d.update(kw)
    return d


def _click_track(bars=4, bpm=120.0, dead_after=None):
    """A bar-exact percussive loop; `dead_after` silences every bar from that index."""
    n = int(round(bars * 4 * 60.0 / bpm * SR))
    y = np.zeros(n, dtype=np.float32)
    per_beat = n // (bars * 4)
    for b in range(bars * 4):
        if dead_after is not None and b // 4 >= dead_after:
            continue
        i = b * per_beat
        env = np.exp(-np.arange(per_beat) / (SR * 0.02))
        y[i:i + per_beat] += (np.random.RandomState(b).randn(per_beat) * env * 0.3
                              ).astype(np.float32)
    return y


def test_a_bar_exact_loop_passes(tmp_path):
    p = _write(tmp_path, "00_kit_4b.wav", _click_track())
    (c,) = K.check_cuts([_cut(p)])
    assert c.ok, c.problems
    assert abs(c.bar_dev_ms) < K.BAR_TOL_MS


def test_a_loop_that_is_not_a_bar_multiple_is_caught(tmp_path):
    y = _click_track()[: -SR // 10]                      # 100 ms short of 4 bars
    p = _write(tmp_path, "01_kit_4b.wav", y)
    (c,) = K.check_cuts([_cut(p)])
    assert not c.ok
    assert any("not a bar multiple" in x for x in c.problems)


def test_dead_bars_are_caught_even_though_the_loop_is_mechanically_perfect(tmp_path):
    """The whole reason this module exists: silence is a clean seam and an exact
    length, so `grade` has no way to object to a loop that stops halfway."""
    p = _write(tmp_path, "02_kit_4b.wav", _click_track(dead_after=2))
    (c,) = K.check_cuts([_cut(p)])
    assert not c.ok
    assert any("dead bar" in x for x in c.problems)


def test_near_duplicates_within_a_kit_are_flagged(tmp_path):
    y = _click_track()
    a = _write(tmp_path, "00_alldrums_4b.wav", y)
    b = _write(tmp_path, "01_kit_4b.wav", y * 0.9)      # the same groove, another stem
    checks = K.check_cuts([_cut(a), _cut(b)])
    assert checks[1].dupe_of == checks[0].name


def test_distinct_material_is_not_flagged_as_duplicate(tmp_path):
    rs = np.random.RandomState(0)
    a = _write(tmp_path, "00_a_4b.wav", _click_track())
    b = _write(tmp_path, "01_b_4b.wav", (rs.randn(int(4 * 4 * 0.5 * SR)) * 0.1).astype("float32"))
    checks = K.check_cuts([_cut(a), _cut(b, bars=4, bpm=120.0)])
    assert checks[1].dupe_of is None


def test_a_wrong_sample_rate_is_caught(tmp_path):
    p = _write(tmp_path, "03_kit_4b.wav", _click_track(), sr=48000)
    (c,) = K.check_cuts([_cut(p, bpm=130.909)])
    assert not c.ok
    assert any("sample rate" in x for x in c.problems)


def test_a_steady_tone_is_not_percussive_however_many_onsets_are_reported():
    """librosa's peak-picker finds peaks in a flat envelope — a steady 50 Hz sine
    yields 84 onsets over six seconds (14/s). Without the crest gate that files a pure
    bass tone as drums, which is how the bass override came to be unreachable."""
    p = R.probe(_sine(50), SR, claimed="tonal")
    assert p.onset_rate > 3.0          # the count really is that high
    assert p.env_crest < 2.0           # and the envelope really is flat
    assert p.measured_family == "bass"


def test_the_producers_label_survives_a_weak_contradiction():
    """MAREA's KEYS2 measures 5.1 onsets/s — genuinely rhythmic playing. Filing it as
    drums would be silently wrong, so a known label wins and the disagreement is
    reported instead."""
    name = R.parse_stem_name("MAREA MIX10 123BPM KEYS2 STEM.wav")
    p = R.probe(_sine(660), SR, claimed="tonal")
    p.measured_family, p.agrees = "drums", False
    assert R.effective_family(name, p) == "tonal"


def test_a_fallback_guess_loses_to_the_measurement():
    """`MAREA STEM` is not a role token; `tonal` there is our own guess, not the
    producer's word, so it carries no authority against a measurement."""
    name = R.parse_stem_name("MAREA MIX10 123BPM MAREA STEM.wav")
    assert name.known is False
    p = R.probe(_sine(660), SR, claimed="tonal")
    p.measured_family, p.agrees = "drums", False
    assert R.effective_family(name, p) == "drums"


def test_the_periodicity_floor_depends_on_the_family(tmp_path):
    """A drum loop that does not repeat at the bar is broken; a vocal phrase that does
    not repeat within itself is a vocal phrase. One threshold for both condemned 12 of
    20 vocal loops for being vocal."""
    rs = np.random.RandomState(3)
    y = (rs.randn(int(4 * 4 * 0.5 * SR)) * 0.05).astype("float32")   # no bar period
    p = _write(tmp_path, "00_x_4b.wav", y)
    (drums,) = K.check_cuts([_cut(p, family="drums", bars=4, bpm=120.0)])
    (vox,) = K.check_cuts([_cut(p, family="vox", bars=4, bpm=120.0)])
    assert any("periodicity" in x for x in drums.problems)
    assert not any("periodicity" in x for x in vox.problems)


def test_periodicity_does_not_penalise_a_short_loop_for_being_short():
    """At lag k only N-k frames overlap, so normalising by ac[0] scores identical
    material lower the shorter the loop: measured 0.418/0.623/0.754 at 2/4/8 bars on a
    perfect click track. The unbiased estimator has to come out flat."""
    scores = [K.periodicity(_click_track(bars=b), SR, b) for b in (2, 4, 8)]
    assert min(scores) > 0.5                      # all read as clearly periodic
    assert max(scores) - min(scores) < 0.25       # and length is not what decides it


def test_a_loop_that_fades_out_halfway_never_ships(tmp_path):
    """The export gate, not just the audit: grade cannot object to this (silence is a
    perfect seam and the length is exact) so it has to be caught before it is written."""
    import engine.stempack as SP
    clip = _click_track(dead_after=2)[:, None]
    assert SP._has_dead_bars(clip, SR, 4)
    assert not SP._has_dead_bars(_click_track()[:, None], SR, 4)


def test_headroom_is_one_gain_for_the_whole_kit_and_never_boosts():
    """Per-file normalisation would make a sparse hat loop as loud as a full drum bus
    and throw away the balance two loops from one kit otherwise have for free. And a
    quiet kit is quiet on purpose — lifting it amplifies its noise floor."""
    import engine.stempack as SP
    hot = SP.kit_headroom_gain([0.2, -3.0, -12.0])
    assert 20 * np.log10(hot) == pytest.approx(-6.2, abs=0.05)
    assert SP.kit_headroom_gain([-15.0, -20.0]) == 1.0      # never boosts
    assert SP.kit_headroom_gain([-6.0]) == 1.0              # already at the ceiling
    assert SP.kit_headroom_gain([]) == 1.0


def test_headroom_preserves_relative_levels_within_a_kit():
    import engine.stempack as SP
    peaks = [0.0, -6.0, -18.0]
    g = SP.kit_headroom_gain(peaks)
    after = [p + 20 * np.log10(g) for p in peaks]
    assert max(after) == pytest.approx(-6.0, abs=0.05)
    # the spacing between the loops is untouched, which is the whole point
    assert [a - after[0] for a in after] == pytest.approx([p - peaks[0] for p in peaks])


def test_a_staged_kit_records_the_grade_of_what_it_actually_wrote(tmp_path, monkeypatch):
    """kitgate compares the manifest against a fresh grade of the file. If staging moves
    the audio after grading, that comparison is between two different signals and the
    difference gets reported as a defect — which is how this bug was found."""
    import engine.stempack as SP
    from engine import grade as G
    y = _click_track()
    y = y / (np.max(np.abs(y)) + 1e-9)              # full scale ⇒ staging will engage
    clip = np.stack([y, y], axis=1)
    head = SP.kit_headroom_gain([0.0])
    assert head < 1.0
    before = G.grade_array(clip.T, SR).grade
    after = G.grade_array((clip * head).T, SR).grade
    assert abs(before - after) > 0.02               # the grade really does move


# ── the gate's own listing check ─────────────────────────────────────────────
def _kitgate():
    import importlib.util
    spec = importlib.util.spec_from_file_location("kitgate", Path(__file__).parent.parent / "kitgate.py")
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)
    return m


def test_a_clean_kit_dir_matches_its_manifest(tmp_path):
    cuts = [_cut(_write(tmp_path, f"{i:02d}_kit_4b.wav", _click_track())) for i in range(3)]
    ok, lines = _kitgate().check_indices(cuts)
    assert ok, lines


def test_a_leftover_from_an_earlier_cut_shifts_every_index_after_it(tmp_path):
    """The failure this check exists for: export writes into a kit dir it never empties,
    so re-cutting `00_kit_2b` as `00_kit_4b` leaves BOTH on disk. SuperDirt enumerates
    the folder, so `n 1` now plays what the cheat sheet calls `n 0` — and nothing about
    the manifest, the grades or the audio looks wrong."""
    cuts = [_cut(_write(tmp_path, f"{i:02d}_kit_4b.wav", _click_track())) for i in range(3)]
    _write(tmp_path, "00_kit_2b.wav", _click_track(bars=2))     # the stale leftover
    g = _kitgate()
    ok, lines = g.check_indices(cuts)
    assert not ok
    assert "orphan" in " ".join(lines)
    assert "n 0 onward" in " ".join(lines)      # it says WHERE the addressing breaks
    assert g.prune_orphans(cuts) == 1
    assert g.check_indices(cuts)[0]


def test_an_orphan_that_sorts_last_is_still_reported_but_indices_hold(tmp_path):
    """A trailing leftover pollutes the bank without moving any index — a different
    severity, and the report has to say which one it is."""
    cuts = [_cut(_write(tmp_path, f"{i:02d}_kit_4b.wav", _click_track())) for i in range(3)]
    _write(tmp_path, "09_kit_4b.wav", _click_track())
    ok, lines = _kitgate().check_indices(cuts)
    assert not ok
    assert "indices still line up" in " ".join(lines)


# ── restaging is idempotent ──────────────────────────────────────────────────
def _restage():
    import importlib.util
    spec = importlib.util.spec_from_file_location("restage", Path(__file__).parent.parent / "restage.py")
    m = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(m)
    return m


def test_restaging_a_staged_kit_is_a_no_op(tmp_path):
    """Export stages in float and writes PCM_24, so the peak read back off disk sits a
    quantization step away from the ceiling it aimed at — which read as "still too hot"
    and asked for another -0.00003 dB, every run, requantizing every file each time."""
    r = _restage()
    y = _click_track()
    y = y / (np.max(np.abs(y)) + 1e-9)
    _write(tmp_path, "00_kit_4b.wav", y)
    _write(tmp_path, "01_kit_4b.wav", y * 0.5)
    first = r.restage_kit(tmp_path)
    assert first["changed"] and first["gain_db"] == pytest.approx(-6.0, abs=0.05)
    second = r.restage_kit(tmp_path)
    assert not second["changed"], second      # the run that used to loop forever
    assert r.restage_kit(tmp_path, dry_run=True)["changed"] is False


def test_a_kit_that_really_is_too_hot_is_still_restaged(tmp_path):
    """The dead band must not swallow a gain that matters — 0.05 dB, not 0.5."""
    r = _restage()
    y = _click_track()
    _write(tmp_path, "00_kit_4b.wav", y / (np.max(np.abs(y)) + 1e-9) * 0.9)
    out = r.restage_kit(tmp_path, dry_run=True)
    assert out["changed"] and out["gain_db"] < -5.0


# ── silence is not a loop ────────────────────────────────────────────────────
def test_a_silent_loop_passes_every_structural_test_and_is_still_rejected(tmp_path):
    """The deepest version of the dead-bar trap. A window of pure silence is bar-exact,
    has a perfect seam, zero DC, trivial zero crossings — and a perfectly FLAT bar
    profile, so the dead-bar check (which compares bars to each other) sees nothing
    wrong. 24 of the 153 loops cut from the Fred pack were digital silence at -104…-75
    dBFS peak, graded 0.707-0.896, tier A and B."""
    sr = SR
    n = int(round(4 * 4 * 60.0 / 120.0 * sr))
    quiet = (np.random.RandomState(0).randn(n) * 1e-5).astype(np.float32)  # ~ -95 dBFS
    p = _write(tmp_path, "00_bass_4b.wav", quiet)
    lv = K.bar_levels(quiet, sr, 4)
    assert max(lv) - min(lv) < K.DEAD_BAR_DB          # the old check sees a clean loop
    (c,) = K.check_cuts([_cut(p, family="bass")])
    assert not c.ok
    assert any("empty" in x for x in c.problems), c.problems


def test_the_two_presence_lenses_use_one_number():
    """kitcheck and grade both decide 'is there audio here'. If they used two constants
    one would eventually be edited alone, and the gate would pass what the grader
    failed."""
    from engine import grade as G
    assert K.EMPTY_DBFS == G.THRESH["empty_dbfs"]
