"""Tests for the lyric-aware vocal sampler (engine/vox.py).

Transcription is ALWAYS mocked — whisper never runs in tests. We inject a
whisper-shaped transcript dict (segments[].words[]) and exercise the pure
pipeline: phrase grouping on gaps, iconicity (repetition + hook keyword),
cut edge properties (zc + fade ⇒ edges ≈ 0), beat/loop annotation, and the
lyric-slug naming / lint contract.
"""
import sys
from pathlib import Path

import numpy as np

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

from engine import vox as V
from engine import naming as N


# ── helpers ──────────────────────────────────────────────────────────────────
def _w(text, start, end, prob=0.9):
    return {"word": text, "start": start, "end": end, "probability": prob}


def _transcript(words):
    """Wrap a flat word list as a single whisper segment dict."""
    return {"segments": [{"words": words}]}


# ── phrase segmentation ────────────────────────────────────────────────────────
def test_segment_splits_on_gap():
    # two words tight, then a 0.6s gap, then two more → two phrases
    words = V.words_from_transcript(_transcript([
        _w(" she's", 0.0, 0.3), _w(" super", 0.3, 0.6),
        _w(" freak", 1.5, 1.8), _w(" now", 1.8, 2.0),
    ]))
    phrases = V.segment_phrases(words, gap_s=0.45)
    assert len(phrases) == 2
    assert phrases[0].text == "she's super"
    assert phrases[1].text == "freak now"


def test_segment_no_split_within_gap():
    words = V.words_from_transcript(_transcript([
        _w(" a", 0.0, 0.2), _w(" b", 0.3, 0.5), _w(" c", 0.6, 0.8),
    ]))
    phrases = V.segment_phrases(words, gap_s=0.45)
    assert len(phrases) == 1
    assert phrases[0].text == "a b c"


def test_segment_splits_overlong_phrase_at_widest_gap():
    # one contiguous run (all gaps < 0.45) but > max_phrase_s → split at widest gap
    words = V.words_from_transcript(_transcript([
        _w(" one", 0.0, 1.0), _w(" two", 1.2, 2.0),
        _w(" three", 2.4, 3.0), _w(" four", 3.1, 4.0),
    ]))
    phrases = V.segment_phrases(words, gap_s=0.45, max_phrase_s=3.0)
    assert len(phrases) == 2                    # split, widest interior gap is 2.0→2.4
    assert phrases[0].text == "one two"
    assert phrases[1].text == "three four"


def test_phrase_conf_is_mean_word_prob():
    words = V.words_from_transcript(_transcript([
        _w(" x", 0.0, 0.2, prob=0.8), _w(" y", 0.2, 0.4, prob=1.0),
    ]))
    p = V.segment_phrases(words)[0]
    assert abs(p.conf - 0.9) < 1e-6


# ── iconicity ────────────────────────────────────────────────────────────────
def _phrase(text, start=0.0, dur=1.0, conf=0.9):
    return V.Phrase(text=text, start=start, end=start + dur, conf=conf)


def test_iconicity_repetition_beats_oneoff():
    # "super freak" recurs 3×; "random mumble" once → repeated phrase ranks higher
    phrases = [
        _phrase("super freak", 0), _phrase("super freak", 5),
        _phrase("super freak", 10), _phrase("random mumble here", 15),
    ]
    ranked = V.rank_phrases(phrases, hooks=None)
    assert ranked[0].text == "super freak"
    hook = next(p for p in ranked if p.text == "super freak")
    oneoff = next(p for p in ranked if p.text == "random mumble here")
    assert hook.repeat > oneoff.repeat
    assert hook.iconicity > oneoff.iconicity


def test_iconicity_hook_keyword_boost():
    a = _phrase("super freak", 0)
    b = _phrase("walking down street", 5)
    V.rank_phrases([a, b], hooks=["super", "freak"])
    assert a.hook > b.hook
    assert a.iconicity > b.iconicity


def test_hooks_from_title_drops_boilerplate():
    hooks = V.hooks_from_title("Soul Sugar meets Dub Shepherds ft. Jolly Joseph ★ Super freak ★")
    assert "super" in hooks and "freak" in hooks
    assert "meets" not in hooks and "ft" not in hooks


# ── beat / loop annotation ─────────────────────────────────────────────────────
def test_annotate_beats_flags_loop_capable():
    beat_s = 60.0 / 132.5
    on_grid = _phrase("kinky girl", 0.0, dur=4 * beat_s)   # exactly a bar
    off_grid = _phrase("home to mother", 0.0, dur=2.5 * beat_s)  # 2.5 beats, off
    V.annotate_beats([on_grid, off_grid], bpm=132.5)
    assert on_grid.loop_capable and on_grid.loop_beats == 4
    assert abs(on_grid.beats - 4.0) < 0.05
    assert not off_grid.loop_capable


# ── cut edge properties ─────────────────────────────────────────────────────────
def _tone(sr, dur, freq=220.0, amp=0.5):
    t = np.arange(int(sr * dur)) / sr
    return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)


def test_cut_edges_near_zero_after_fade():
    sr = 44100
    y = _tone(sr, 3.0)                      # continuous tone: no natural zero at cut point
    p = _phrase("test phrase", start=1.0, dur=0.6)
    clip = V.cut_phrase(y, sr, p)
    assert clip.shape[0] > 100
    # fade guarantees the very edges are ~0 regardless of zc landing
    assert abs(float(clip[0, 0])) < 1e-3
    assert abs(float(clip[-1, 0])) < 1e-3


def test_cut_respects_next_start():
    sr = 44100
    y = _tone(sr, 4.0)
    p = _phrase("first", start=1.0, dur=0.5)   # ends 1.5; post-pad would reach ~1.62
    clip = V.cut_phrase(y, sr, p, next_start=1.55)
    end_t = 1.0 - V.PRE_PAD_S + clip.shape[0] / sr
    assert end_t <= 1.55 + 0.02                # never runs into the next phrase


def test_cut_phrase_loop_is_exact_beat_multiple():
    # length-rigid grid cut: the tail sits at EXACTLY loop_beats × beat_s from the
    # snapped anchor (no end-side zc-snap drift) — duration is grid-exact to <1 ms.
    sr, bpm = 44100, 132.5
    beat_s = 60.0 / bpm
    y = _tone(sr, 6.0)
    p = _phrase("super freak", start=1.0, dur=4 * beat_s)
    V.annotate_beats([p], bpm=bpm)
    assert p.loop_capable
    clip = V.cut_phrase_loop(y, sr, p, bpm=bpm)
    dur = clip.shape[0] / sr
    assert abs(dur - p.loop_beats * beat_s) < 1e-3


def test_cut_removes_source_dc_offset():
    # demucs stems carry a small DC bias; a cut one-shot must ship DC-free (B2)
    sr = 44100
    y = _tone(sr, 3.0) + 5e-4                     # biased source, above the 1e-4 flag
    p = _phrase("dc test", start=1.0, dur=1.0)
    clip = V.cut_phrase(y, sr, p)
    assert abs(float(clip.mean())) < 1e-4
    g = V.grade_cut(clip, sr)
    assert "dc-offset" not in g.flags


def test_cut_phrase_loop_none_when_not_capable():
    sr = 44100
    y = _tone(sr, 3.0)
    p = _phrase("off grid", start=0.5, dur=0.37)
    V.annotate_beats([p], bpm=132.5)
    if not p.loop_capable:
        assert V.cut_phrase_loop(y, sr, p) is None


# ── lyric slug naming / lint ─────────────────────────────────────────────────────
def test_lyric_slug_from_content_words():
    assert V.lyric_slug("she's a super freak") == "superfreak"      # stopwords dropped
    assert V.lyric_slug("the kind you don't take home to mother") == "kindtakehome"


def test_lyric_slug_falls_back_on_all_stopwords():
    slug = V.lyric_slug("oh yeah")
    assert slug and slug.isalnum()


def test_lyric_name_passes_lint():
    slug = V.lyric_slug("she's a very kinky girl")
    name = N.suggest_name(3, "vox", character=slug)
    assert N.lint(name) == []
    assert name.startswith("03_vox_")


def test_lyric_name_dedupes():
    slug = V.lyric_slug("super freak")
    taken = {N.suggest_name(3, "vox", character=slug)}
    n2 = N.suggest_name(4, "vox", character=slug, taken=taken)
    assert n2 not in taken
    assert N.lint(n2) == []


def test_grade_cut_returns_one_shot_for_short():
    sr = 44100
    clip = _tone(sr, 0.5)[:, None]             # 0.5s < one_shot threshold
    g = V.grade_cut(clip, sr)
    assert g.kind == "one_shot"
