"""Tests for the loop grader (engine/grade.py, the katana).

Assertions are over SYNTHETIC signals with known properties, and are RELATIVE
(a perfect loop seams better than a torn one) rather than pinned to the v1
thresholds — those get calibrated against the corpus in tasks #11/#7, so pinning
them here would just freeze provisional numbers.

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

import numpy as np

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

from engine import grade as G


def _sine(freq=100.0, sr=44100, periods=200, dc=0.0, amp=0.3):
    """A sine of an INTEGER number of periods → wraps seamlessly."""
    n = int(round(sr * periods / freq))
    t = np.arange(n) / sr
    return amp * np.sin(2 * np.pi * freq * t) + dc, sr


def test_one_shot_classified():
    y, sr = _sine(periods=20)              # ~0.2 s ≪ 0.75 s
    g = G.grade_array(y, sr)
    assert g.kind == "one_shot" and "one-shot" in g.flags


def test_loop_vs_oneshot_threshold():
    short, sr = _sine(periods=60)          # 0.6 s
    long, _ = _sine(periods=120)           # 1.2 s
    assert G.grade_array(short, sr).kind == "one_shot"
    assert G.grade_array(long, sr).kind == "loop"


def test_perfect_loop_seams_better_than_torn():
    perfect, sr = _sine(periods=200)       # integer periods → tiny wrap step
    torn = perfect[: len(perfect) // 2 - 113]   # cut mid-period → end ≠ start
    s_perfect, _ = G.seam_score(perfect)
    s_torn, _ = G.seam_score(torn)
    assert s_perfect > s_torn
    assert s_perfect > 0.7                 # a clean wrap should grade high


def test_dc_offset_detected_and_flagged():
    clean, sr = _sine(dc=0.0)
    dirty, _ = _sine(dc=0.05)
    s_clean, m_clean = G.dc_score(clean)
    s_dirty, m_dirty = G.dc_score(dirty)
    assert abs(m_dirty) > abs(m_clean)
    assert s_dirty < s_clean
    assert "dc-offset" in G.grade_array(dirty, sr).flags


def test_clipping_flagged():
    y, sr = _sine(amp=2.0)                 # amp 2.0 → clips when read as peak ~0 dBFS
    y = np.clip(y, -0.9999, 0.9999)
    score, rms_db, peak_db, clipped = G.level_score(y)
    assert clipped and peak_db > -1.0
    assert "clipping" in G.grade_array(y, sr).flags


def test_silence_is_empty_not_merely_near_silent():
    """-62 dBFS peak is not quiet material, it is nothing — and nothing used to grade A.
    Every other sub-score is vacuously perfect on silence (a seam between two silences,
    zero DC, trivial zero crossings), so the composite's optimum was "no audio at all"
    and the finder walked straight into it: 24 of 153 loops cut from the Fred pack were
    digital silence at -104…-75 dBFS, graded 0.707-0.896."""
    y, sr = _sine(amp=0.0008)              # ~ -62 dBFS
    g = G.grade_array(y, sr)
    assert "empty" in g.flags
    assert g.grade == 0.0 and g.tier == "D"


def test_a_sparse_loop_is_near_silent_but_not_empty():
    """RMS and peak answer different questions. A hat loop with peaks at -14.7 dBFS reads
    -48.8 RMS because RMS measures how much SILENCE a sparse signal contains — that is
    advisory, not disqualifying, and it must not be confused with an empty file."""
    sr, rng = 44100, np.random.RandomState(0)
    y = np.zeros(sr * 4)
    for i in range(4):                     # four very short ticks in four seconds
        j = i * sr
        y[j:j + 32] = 0.3 * rng.randn(32) * np.hanning(32)
    g = G.grade_array(y, sr)
    assert g.metrics["peak_dbfs"] - g.metrics["rms_dbfs"] > 30    # a real sparse crest
    g = G.grade_array(y, sr)
    assert "near-silent" in g.flags        # RMS is way down …
    assert "empty" not in g.flags          # … but there is clearly audio here
    assert g.grade > 0.0


def test_an_anti_phase_pair_is_mono_incompatible_not_empty():
    """The presence test has to look at the channels, not the mono sum: L/-R sums to
    exact zero, and calling that "empty" would suppress the one flag worth raising."""
    mono, sr = _sine(periods=200)
    g = G.grade_array(np.stack([mono, -mono]), sr)
    assert "empty" not in g.flags
    assert "mono-incompatible" in g.flags


def test_mono_compatible_vs_anti_phase():
    mono, sr = _sine(periods=200)
    in_phase = np.stack([mono, mono])      # L == R → mono-sums fine
    anti = np.stack([mono, -mono])         # L == -R → cancels on mono-sum
    s_in, c_in = G.bass_mono_score(in_phase)
    s_anti, c_anti = G.bass_mono_score(anti)
    assert s_in > 0.9 and c_in > 0.9
    assert s_anti < 0.1 and c_anti < -0.9
    assert "mono-incompatible" in G.grade_array(anti, sr).flags


def test_mono_file_is_na_not_penalized():
    y, sr = _sine(periods=200)             # 1-D → mono
    score, corr = G.bass_mono_score(y)
    assert score == 1.0                    # N/A, full marks


def _click_train(bpm=120.0, sr=44100, beats=8, decay=0.01):
    """Impulses at `bpm` quarter notes → a signal with a real, detectable tempo."""
    spb = 60.0 / bpm                       # seconds per beat
    n = int(round(sr * spb * beats))
    y = np.zeros(n)
    env_len = int(sr * decay)
    env = np.exp(-np.arange(env_len) / (sr * decay / 5))
    for b in range(beats):
        i = int(round(b * spb * sr))
        y[i:i + env_len] += env[: max(0, min(env_len, n - i))][: n - i]
    return y, sr


def test_bar_consistency_finds_musical_interpretation():
    # 8 quarter-note clicks @ 120 bpm = 4.0 s = 2 bars; tempo is genuinely detectable.
    y, sr = _click_train(bpm=120.0, beats=8)
    score, info = G.bar_consistency_score(y, sr)
    assert info["best_bars"] in G.THRESH["bars"]
    assert score > 0.3                     # a clean 2-bar @ 120 grid should grade well


def test_off_grid_duration_scores_zero():
    # 3.33 s with no {1,2,4,8}-bar interpretation in [60,200] bpm.
    sr = 44100
    n = int(sr * 3.33)
    y = 0.3 * np.sin(2 * np.pi * 110 * np.arange(n) / sr)
    # force the no-tempo path by checking the math: bars*4*60/3.33 for 1,2,4,8
    implied = [b * 4 * 60 / 3.33 for b in (1, 2, 4, 8)]
    in_range = [v for v in implied if 60 <= v <= 200]
    if not in_range:                       # only assert when truly off-grid
        score, info = G.bar_consistency_score(y, sr)
        assert info["best_bars"] == 0 and score == 0.0


def test_grade_shape_and_tier_monotonic():
    y, sr = _sine(periods=200)
    g = G.grade_array(y, sr)
    assert g.tier in {"S", "A", "B", "C", "D"}
    assert 0.0 <= g.grade <= 1.0
    assert set(g.sub) == {"seam", "bar", "zc", "level", "dc", "bass_mono"}
    assert g.model_dump()["path"] == "<array>"   # serializes for the tierlist


def test_tier_of_boundaries():
    y, sr = _sine(periods=200)
    g = G.grade_array(y, sr)
    g.grade = 0.95; assert g.tier_of() == "S"
    g.grade = 0.85; assert g.tier_of() == "A"
    g.grade = 0.72; assert g.tier_of() == "B"
    g.grade = 0.60; assert g.tier_of() == "C"
    g.grade = 0.10; assert g.tier_of() == "D"


def test_load_audio_roundtrip(tmp_path):
    import soundfile as sf
    y, sr = _sine(periods=200)
    p = tmp_path / "loop.wav"
    sf.write(str(p), y.astype(np.float32), sr)
    loaded, lsr = G.load_audio(p)
    assert lsr == sr
    assert loaded.ndim == 1 or loaded.shape[0] == 1   # mono written, mono read
    g = G.grade(p)
    assert g.path == str(p) and g.kind == "loop"
