"""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_near_silent_flagged():
    y, sr = _sine(amp=0.0008)              # ~ -62 dBFS
    assert "near-silent" in G.grade_array(y, sr).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"
