"""Tests for the loop finder (engine/loops.py).

Pure DSP/logic on synthetic inputs, plus one light integration (beat_grid tempo
recovery, which guards the §7 octave fix). Composite scoring weights are v1 and
calibrated in #7, so assertions are structural/relative, not pinned to thresholds.
"""
import sys
from pathlib import Path

import numpy as np

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

from engine import loops as L


def test_checkerboard_shape_and_signs():
    k = 4
    ker = L._checkerboard(k)
    assert ker.shape == (2 * k, 2 * k)
    # top-left & bottom-right quadrants positive; off-diagonal quadrants negative
    assert ker[:k, :k].sum() > 0 and ker[k:, k:].sum() > 0
    assert ker[:k, k:].sum() < 0 and ker[k:, :k].sum() < 0


def test_foote_novelty_peaks_at_block_boundary():
    # two self-similar blocks → a checkerboard novelty spike at their seam (idx 10)
    n = 20
    ssm = np.zeros((n, n))
    ssm[:10, :10] = 1.0
    ssm[10:, 10:] = 1.0
    nov = L.foote_novelty(ssm, 4)
    assert 8 <= int(np.argmax(nov)) <= 12


def test_structural_high_when_window_repeats():
    n = 8
    ssm = np.eye(n)
    # window [0:2] strongly resembles [4:6] elsewhere
    ssm[0, 4] = ssm[1, 5] = ssm[4, 0] = ssm[5, 1] = 1.0
    rep = L._structural(ssm, 0, 2)
    uniq = L._structural(ssm, 2, 4)
    assert rep > uniq


def _cand(start, end, score, bars=2):
    return L.LoopCandidate(start_s=start, end_s=end, bars=bars, bpm=120,
                           tempo_unstable=False, score=score, structural=0.5,
                           novelty=0.5, seam=0.5, zc=0.5)


def test_overlap_fraction():
    a, b = _cand(0, 4, .9), _cand(2, 6, .8)
    assert 0.4 < L._overlap(a, b) < 0.6           # 2s overlap of 4s windows
    assert L._overlap(_cand(0, 4, .9), _cand(10, 14, .8)) == 0.0


def test_dedup_keeps_best_drops_overlap():
    cands = [_cand(0, 4, .9), _cand(2, 6, .8), _cand(20, 24, .7)]
    out = L._dedup(cands, top_n=8)
    starts = sorted(c.start_s for c in out)
    assert starts == [0.0, 20.0]                  # the .8 overlapping the .9 is dropped


def test_snap_zc_moves_to_crossing():
    sr = 44100
    t = np.arange(sr) / sr
    y = np.sin(2 * np.pi * 100 * t).astype(np.float32)  # zero crossings every 220.5 samp
    idx = 1000
    snapped = L._snap_zc(y, idx, sr)
    assert abs(y[snapped]) < abs(y[idx]) + 1e-3   # lands on/near a crossing
    assert abs(snapped - idx) <= int(sr * L.ZC_TOL_MS / 1000)


def test_analyze_stem_honors_shared_grid():
    # a sparse stem (few onsets) gets no usable per-stem grid, but a shared grid
    # from a rhythmic source lets it produce candidates (#18 grid-from-drums).
    sr = 22050
    sparse = (0.05 * np.random.default_rng(3).standard_normal(sr * 12)).astype(np.float32)
    # build a 120-bpm grid (beats every 0.5 s) as (peaks_frames, times, bpm)
    times = np.arange(0, 12, 0.5)
    peaks = (times * sr / 512).astype(int)
    bpm = np.full(len(times), 120.0)
    cands = L.analyze_stem(sparse, sr, bars=(2,), top_n=4, grid=(peaks, times, bpm))
    assert isinstance(cands, list)            # runs on the shared grid without recomputing
    for c in cands:
        assert 100 <= c.bpm <= 140            # uses the supplied grid's tempo


def test_beat_grid_recovers_tempo_not_its_octave():
    # 120 bpm click train; the fix must report ~120, NOT ~240
    sr = 22050
    spb = 0.5
    n = int(sr * spb * 16)
    y = np.zeros(n, dtype=np.float32)
    for b in range(16):
        i = int(b * spb * sr)
        y[i:i + 200] += np.hanning(200).astype(np.float32)
    _, times, bpm = L.beat_grid(y, sr)
    assert len(times) >= 8
    med = float(np.median(bpm))
    assert 100 <= med <= 140                       # not the 240 octave
