"""Tests for lib/notation_lift.py — the reusable wire → notation heuristics.

These cover the extracted core directly (independent of the
scripts/lift_keys_notation.py CLI glue): wire decode, simultaneity grouping,
hand split, downbeat/tempo derivation, duration quantization, and full
``build_notation`` assembly including anacrusis and tie-across-barline.
"""

from __future__ import annotations

import pytest

import notation
import notation_lift as nl


# ── Helpers ──────────────────────────────────────────────────────────────────

def _wire(t: float, midi: int, sus: float = 0.0) -> dict:
    """Guitar-wire keys note: midi packed as s*24 + f."""
    return {"t": t, "s": midi // 24, "f": midi % 24, "sus": sus}


def _beats_4_4(n_measures: int, bpm: float = 120.0) -> list[dict]:
    """Song-level downbeats for ``n_measures`` bars of 4/4 at a steady tempo."""
    secs_per_measure = (4 * 4.0 / 4) * 60.0 / bpm  # 4 quarter notes
    return [{"time": round(i * secs_per_measure, 6), "measure": i}
            for i in range(n_measures)]


# ── Wire decoding ────────────────────────────────────────────────────────────

def test_decode_wire_notes_unpacks_midi_and_sorts():
    arr = {"notes": [_wire(1.0, 67, 0.5), _wire(0.0, 60)]}
    out = nl.decode_wire_notes(arr)
    assert [n["midi"] for n in out] == [60, 67]
    assert out[0] == {"t": 0.0, "midi": 60, "sus": 0.0}
    assert out[1]["sus"] == 0.5


def test_decode_wire_notes_includes_chord_notes_at_chord_time():
    # Chord notes carry no own time → they sound at the chord's t.
    chord_note = lambda midi: {"s": midi // 24, "f": midi % 24}
    arr = {"chords": [{"t": 2.0, "notes": [chord_note(60), chord_note(64)]}]}
    out = nl.decode_wire_notes(arr)
    assert {n["midi"] for n in out} == {60, 64}
    assert all(n["t"] == 2.0 for n in out)


def test_decode_wire_notes_skips_malformed_and_out_of_range():
    arr = {"notes": [{"t": "x", "s": 2, "f": 5}, {"t": 0.0, "s": 99, "f": 0}]}
    assert nl.decode_wire_notes(arr) == []


def test_decode_wire_notes_accepts_legacy_l_sustain_alias():
    out = nl.decode_wire_notes({"notes": [{"t": 0.0, "s": 2, "f": 12, "l": 0.75}]})
    assert out[0]["sus"] == 0.75


# ── Simultaneity grouping ────────────────────────────────────────────────────

def test_group_simultaneous_buckets_within_window():
    notes = [{"t": 0.0, "midi": 60}, {"t": 0.005, "midi": 64},
             {"t": 0.5, "midi": 67}]
    groups = nl.group_simultaneous(notes)
    assert [len(g) for g in groups] == [2, 1]


# ── Hand split ───────────────────────────────────────────────────────────────

def test_split_hands_wide_span_splits_at_largest_gap_low_to_lh():
    # 36 (C2) ... 72 (C5): span 36 > 12 → split at the largest internal gap.
    notes = [{"t": 0.0, "midi": 36, "sus": 0}, {"t": 0.0, "midi": 72, "sus": 0}]
    hands = nl.split_hands(notes)
    assert hands["lh"][0]["midi"] == 36
    assert hands["rh"][0]["midi"] == 72


def test_split_hands_narrow_group_goes_by_mean_vs_middle_c():
    low = nl.split_hands([{"t": 0.0, "midi": 48, "sus": 0},
                          {"t": 0.0, "midi": 52, "sus": 0}])
    assert "lh" in low and "rh" not in low  # mean 50 < 60
    high = nl.split_hands([{"t": 0.0, "midi": 64, "sus": 0},
                           {"t": 0.0, "midi": 67, "sus": 0}])
    assert "rh" in high and "lh" not in high  # mean ~65.5 ≥ 60


# ── Timing ───────────────────────────────────────────────────────────────────

def test_downbeat_times_filters_non_downbeats_and_sorts():
    beats = [{"time": 2.0, "measure": 1}, {"time": 0.5, "measure": -1},
             {"time": 0.0, "measure": 0}]
    assert nl.downbeat_times(beats) == [0.0, 2.0]


def test_measure_tempos_from_spacing_and_inherits_last():
    # 2.0 s per 4/4 bar → 120 BPM; last bar inherits previous.
    assert nl.measure_tempos([0.0, 2.0, 4.0], (4, 4)) == pytest.approx([120, 120, 120])
    assert nl.measure_tempos([0.0], (4, 4)) == [120.0]


def test_quantize_duration_floors_at_32nd_and_picks_dotted():
    qn = 60.0 / 120.0  # 0.5 s
    assert nl.quantize_duration(qn, 120.0) == (4, 0)          # quarter
    assert nl.quantize_duration(qn * 1.5, 120.0) == (4, 1)    # dotted quarter
    assert nl.quantize_duration(0.0001, 120.0) == (32, 0)     # floor


# ── Full assembly ────────────────────────────────────────────────────────────

def test_build_notation_returns_none_without_notes_or_downbeats():
    assert nl.build_notation([], _beats_4_4(1)) is None
    assert nl.build_notation(nl.decode_wire_notes({"notes": [_wire(0, 60)]}), []) is None


def test_build_notation_valid_payload_and_staves():
    notes = nl.decode_wire_notes({"notes": [
        _wire(0.0, 60, 0.5), _wire(0.0, 48, 0.5),  # both hands, beat 1
        _wire(0.5, 64, 0.5),
    ]})
    payload = nl.build_notation(notes, _beats_4_4(1), instrument="piano")
    ok, reason = notation.validate_notation(payload)
    assert ok, reason
    assert payload["instrument"] == "piano"
    assert {s["id"] for s in payload["staves"]} == {"rh", "lh"}
    assert payload["measures"][0]["ts"] == [4, 4]


def test_build_notation_marks_pickup_for_anacrusis():
    # An onset before the first downbeat (downbeat at t=0.5) → pickup measure.
    beats = [{"time": 0.5, "measure": 0}, {"time": 2.5, "measure": 1}]
    notes = nl.decode_wire_notes({"notes": [_wire(0.0, 67), _wire(0.5, 72)]})
    payload = nl.build_notation(notes, beats)
    assert payload["measures"][0].get("pickup") is True


def test_build_notation_ties_note_across_barline():
    # A whole-bar sustain that starts mid-bar must split into tied continuations.
    beats = _beats_4_4(2, bpm=120.0)  # 2 s per bar
    notes = nl.decode_wire_notes({"notes": [_wire(1.0, 48, sus=2.0)]})  # spans bar 1→2
    payload = nl.build_notation(notes, beats)
    ok, reason = notation.validate_notation(payload)
    assert ok, reason
    # The continuation in measure 2 must be tied (midi 48 → lh).
    m2 = payload["measures"][1]["staves"]["lh"]["voices"][0]["beats"]
    assert any(n.get("tied") for b in m2 for n in b["notes"])
