"""Tests for lib/lyrics_transcribe.py — WhisperX lyric transcription helpers.

The actual WhisperX inference is too heavy and non-deterministic to test
in CI (multi-GB model download, GPU optional, hallucinations on the
edges). These tests cover the parts that are deterministic and don't
need the model:

* `_whisperx_to_sloppak` — pure dict→dict mapping (score filter,
  line-break heuristic, duration clamp, rounding).
* `vocals_has_signal` — RMS gate over a synthesized WAV.
* `whisperx_available` — graceful False when the package isn't installed.

The end-to-end positive case (archive without lyrics → sloppak with
auto-transcribed lyrics) is intentionally a manual verification step;
see the plan's verification section.
"""

from __future__ import annotations

import importlib

import pytest

from lyrics_transcribe import (
    _whisperx_to_sloppak,
    vocals_has_signal,
    whisperx_available,
)


# ── Mapper: scores, line breaks, durations, rounding ────────────────────────

def test_mapper_passes_words_above_score_threshold():
    aligned = {
        "segments": [
            {"words": [
                {"word": "hello", "start": 1.0, "end": 1.3, "score": 0.9},
                {"word": "world", "start": 1.4, "end": 1.7, "score": 0.8},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.35)
    assert got == [
        {"t": 1.0, "d": 0.3, "w": "hello"},
        {"t": 1.4, "d": 0.3, "w": "world"},
    ]


def test_mapper_drops_words_below_score_threshold():
    aligned = {
        "segments": [
            {"words": [
                {"word": "good", "start": 1.0, "end": 1.2, "score": 0.9},
                {"word": "bad",  "start": 1.3, "end": 1.5, "score": 0.10},
                {"word": "ugly", "start": 1.6, "end": 1.8, "score": 0.05},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.35)
    assert [w["w"] for w in got] == ["good"]


def test_mapper_drops_words_with_missing_score():
    # WhisperX occasionally emits words it failed to localize without a
    # score field — those must drop, not pass-through as untrusted text.
    aligned = {
        "segments": [
            {"words": [
                {"word": "scored",   "start": 1.0, "end": 1.2, "score": 0.9},
                {"word": "unscored", "start": 1.3, "end": 1.5},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert [w["w"] for w in got] == ["scored"]


def test_mapper_inserts_line_break_on_segment_gap_above_threshold():
    # Two segments separated by > 1.5s — the mapper appends `+` to the
    # last word of the previous line. Matches static/highway.js which
    # parses `+` as a suffix marker (raw.endsWith('+')) and strips it
    # before rendering. A bare {"w": "+"} token would be rendered as an
    # empty syllable / blank slot in the overlay.
    aligned = {
        "segments": [
            {"words": [
                {"word": "first", "start": 1.0, "end": 1.2, "score": 0.9},
            ]},
            {"words": [
                {"word": "second", "start": 5.0, "end": 5.3, "score": 0.9},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert got == [
        {"t": 1.0, "d": 0.2, "w": "first+"},
        {"t": 5.0, "d": 0.3, "w": "second"},
    ]


def test_mapper_does_not_insert_line_break_for_close_segments():
    # Gap of 0.5s — well under the threshold, no break syllable.
    aligned = {
        "segments": [
            {"words": [{"word": "a", "start": 1.0, "end": 1.2, "score": 0.9}]},
            {"words": [{"word": "b", "start": 1.7, "end": 1.9, "score": 0.9}]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert [w["w"] for w in got] == ["a", "b"]


def test_mapper_no_line_break_for_singer_breath_gap():
    # Gap of 2.0s — typical between-phrase singer breath on slow vocals.
    # Pinned behavior: must NOT trigger a `+` break (would fragment a
    # verse into one-line-per-phrase). The old 1.5s threshold would
    # have broken here; the current 3.0s default leaves it intact.
    aligned = {
        "segments": [
            {"words": [{"word": "phrase1", "start": 1.0, "end": 1.5, "score": 0.9}]},
            {"words": [{"word": "phrase2", "start": 3.5, "end": 4.0, "score": 0.9}]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert [w["w"] for w in got] == ["phrase1", "phrase2"]


def test_mapper_clamps_zero_duration_to_floor():
    # WhisperX sometimes emits start == end for ultra-short syllables.
    # The lyrics overlay's fade timing needs a non-zero `d`, so the
    # mapper clamps to a small floor.
    aligned = {
        "segments": [
            {"words": [
                {"word": "x", "start": 1.0, "end": 1.0, "score": 0.9},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert got[0]["d"] == 0.05


def test_mapper_rounds_to_three_decimals():
    # Pin the rounding precision so an accidental change doesn't silently
    # shift every downstream lyric timestamp.
    aligned = {
        "segments": [
            {"words": [
                {"word": "hi", "start": 1.234567, "end": 1.876543, "score": 0.9},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert got[0]["t"] == 1.235
    assert got[0]["d"] == 0.642


def test_mapper_skips_empty_word_text():
    # Whitespace-only "word" entries are dropped — the highway overlay
    # would render them as blank syllables otherwise.
    aligned = {
        "segments": [
            {"words": [
                {"word": "   ", "start": 1.0, "end": 1.2, "score": 0.9},
                {"word": "real", "start": 1.3, "end": 1.5, "score": 0.9},
            ]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.0)
    assert [w["w"] for w in got] == ["real"]


def test_mapper_gap_anchors_on_segment_end_not_last_surviving_word():
    # Trailing word of segment 1 gets filtered (low score). prev_end must
    # still advance to the segment's real end, otherwise the gap to
    # segment 2 measures from "first" (1.2s) to "second" (3.0s) = 1.8s
    # and falsely triggers a `+` line break. The segment's actual end
    # is 5.0s, so gap is 3.0 - 5.0 = -2s, no break.
    aligned = {
        "segments": [
            {
                "end": 5.0,
                "words": [
                    {"word": "first", "start": 1.0, "end": 1.2, "score": 0.9},
                    {"word": "dropped", "start": 4.5, "end": 5.0, "score": 0.05},
                ],
            },
            {
                "words": [
                    {"word": "second", "start": 3.0, "end": 3.3, "score": 0.9},
                ],
            },
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.35)
    # Only "first" + "second" survive the filter, no `+` suffix.
    assert got == [
        {"t": 1.0, "d": 0.2, "w": "first"},
        {"t": 3.0, "d": 0.3, "w": "second"},
    ]


def test_mapper_advances_prev_end_past_fully_filtered_segment():
    # Entire middle segment gets filtered out, but prev_end must still
    # advance past it via segment.end. Otherwise the gap from "first"
    # (end 1.2s) to "third" (start 8.0s) registers as 6.8s and triggers
    # a `+` break against "first" — wrong, the actual gap from the
    # filtered segment's true end (7.5s) to "third" (8.0s) is 0.5s.
    aligned = {
        "segments": [
            {"end": 1.5, "words": [{"word": "first", "start": 1.0, "end": 1.2, "score": 0.9}]},
            {"end": 7.5, "words": [{"word": "dropped", "start": 5.0, "end": 7.5, "score": 0.05}]},
            {"words": [{"word": "third", "start": 8.0, "end": 8.4, "score": 0.9}]},
        ],
    }
    got = _whisperx_to_sloppak(aligned, min_score=0.35)
    # No `+` suffix on "first" — short gap to the third segment.
    assert got == [
        {"t": 1.0, "d": 0.2, "w": "first"},
        {"t": 8.0, "d": 0.4, "w": "third"},
    ]


def test_mapper_handles_empty_input():
    assert _whisperx_to_sloppak({}, min_score=0.0) == []
    assert _whisperx_to_sloppak({"segments": []}, min_score=0.0) == []
    assert _whisperx_to_sloppak({"segments": [{"words": []}]}, min_score=0.0) == []


# ── Silence gate (uses soundfile + numpy) ───────────────────────────────────


def _import_soundfile_or_skip():
    """Import soundfile, skip the test if it OR its native libsndfile is
    missing. `pytest.importorskip("soundfile")` alone only catches
    ImportError, but `import soundfile` performs a ctypes load of
    libsndfile at import time — on a host without the native lib it
    raises OSError, which would error the test suite (especially now
    that soundfile is in CI's requirements-test.txt and the wheel is
    expected to be present) instead of cleanly skipping."""
    try:
        import soundfile as sf
        return sf
    except ImportError as e:
        pytest.skip(f"soundfile not installed: {e}")
    except OSError as e:
        pytest.skip(f"soundfile native library unavailable: {e}")


def _make_wav(path, samples, sr: int = 22050):
    sf = _import_soundfile_or_skip()
    sf.write(str(path), samples, sr)


def test_vocals_has_signal_returns_false_for_silent_wav(tmp_path):
    np = pytest.importorskip("numpy")
    _import_soundfile_or_skip()
    silent = np.zeros(22050, dtype="float32")
    p = tmp_path / "silent.wav"
    _make_wav(p, silent)
    assert vocals_has_signal(p, threshold=0.005) is False


def test_vocals_has_signal_returns_true_for_loud_wav(tmp_path):
    np = pytest.importorskip("numpy")
    _import_soundfile_or_skip()
    # 440Hz sine at full scale — clearly above any reasonable threshold.
    t = np.linspace(0, 1.0, 22050, endpoint=False, dtype="float32")
    sine = (0.5 * np.sin(2 * np.pi * 440 * t)).astype("float32")
    p = tmp_path / "tone.wav"
    _make_wav(p, sine)
    assert vocals_has_signal(p, threshold=0.005) is True


def test_vocals_has_signal_open_fails_returns_true(tmp_path):
    # Gate is best-effort: when reading the file fails we let downstream
    # surface the real error rather than mis-classify the input as silent.
    _import_soundfile_or_skip()
    p = tmp_path / "does-not-exist.wav"
    assert vocals_has_signal(p, threshold=0.005) is True


# ── Availability probe ─────────────────────────────────────────────────────

def test_whisperx_available_returns_bool():
    # Doesn't matter whether the test machine has whisperx installed —
    # the probe must return a plain bool, never raise.
    result = whisperx_available()
    assert isinstance(result, bool)


def test_whisperx_available_returns_false_when_import_fails(monkeypatch):
    # Force the import to fail so we exercise the False branch even on
    # machines that happen to have whisperx installed in the test venv.
    import builtins
    real_import = builtins.__import__

    def fake_import(name, *args, **kwargs):
        if name == "whisperx":
            raise ImportError("simulated")
        return real_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, "__import__", fake_import)
    # Force a fresh module import so the deferred `import whisperx` inside
    # whisperx_available() takes the patched path.
    import lyrics_transcribe
    importlib.reload(lyrics_transcribe)
    assert lyrics_transcribe.whisperx_available() is False
    # Reload again unpatched so we don't leave the patched module in
    # sys.modules for downstream tests.
    monkeypatch.undo()
    importlib.reload(lyrics_transcribe)
