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