"""Unit tests for lib/drums.py — piece vocabulary, presets, and wire helpers."""

from __future__ import annotations

import drums


# ── Piece vocabulary ──────────────────────────────────────────────────────────

def test_pieces_have_required_fields():
    """Every piece entry must have midi (list), category, shape, color."""
    required = {"midi", "category", "shape", "color"}
    for pid, meta in drums.PIECES.items():
        assert required <= set(meta.keys()), f"{pid} missing fields"
        assert isinstance(meta["midi"], list) and meta["midi"], f"{pid} midi must be non-empty list"
        assert meta["category"] in {"kick", "drum", "cymbal"}, f"{pid} bad category"
        assert isinstance(meta["color"], str) and meta["color"].startswith("#")


def test_midi_to_piece_round_trip_for_gm_drums():
    """Every MIDI note listed in PIECES round-trips through midi_to_piece."""
    for pid, meta in drums.PIECES.items():
        for m in meta["midi"]:
            resolved = drums.midi_to_piece(m)
            # The reverse map prefers the earliest piece-id for a shared MIDI.
            # The resolved id must still map back to a piece whose midi list
            # contains m, even if it isn't `pid` (e.g. some toms share notes).
            assert resolved is not None, f"MIDI {m} (from {pid}) not in reverse map"
            assert m in drums.PIECES[resolved]["midi"]


def test_midi_to_piece_unknown_returns_none():
    """MIDI notes outside the GM percussion range we map return None."""
    # 60 is middle C — not a drum note.
    assert drums.midi_to_piece(60) is None
    # 200 is out of MIDI range.
    assert drums.midi_to_piece(200) is None


def test_open_and_closed_hihat_are_distinct():
    """hh_open and hh_closed must NOT share MIDI notes — the highway relies on
    this to reject a closed-hat strike on an open-hat note."""
    closed = set(drums.PIECES["hh_closed"]["midi"])
    opened = set(drums.PIECES["hh_open"]["midi"])
    assert closed & opened == set(), "hh_closed and hh_open MUST NOT share MIDI"


def test_piece_to_default_midi_returns_list_copy():
    """piece_to_default_midi must return a fresh list (callers may mutate)."""
    mids = drums.piece_to_default_midi("kick")
    assert mids == [35, 36]
    mids.append(99)
    assert drums.piece_to_default_midi("kick") == [35, 36], "must return a copy"


def test_piece_helpers_have_fallbacks_for_unknown_ids():
    """Unknown piece-ids must round-trip with safe defaults — a newer sloppak's
    unknown piece should still render rather than crash."""
    assert drums.piece_to_default_midi("future_piece") == []
    assert drums.piece_default_shape("future_piece") == "rect"
    assert drums.piece_default_color("future_piece").startswith("#")
    assert drums.piece_category("future_piece") == "drum"


# ── Presets ──────────────────────────────────────────────────────────────────

def test_presets_are_well_formed():
    """Each preset must be a list of lanes with non-empty `pieces` and a label."""
    for name, preset in drums.PRESETS.items():
        assert isinstance(preset, list) and preset, f"preset {name} empty"
        for lane in preset:
            assert isinstance(lane, dict)
            assert "pieces" in lane and lane["pieces"], f"preset {name}: lane missing pieces"
            assert "label" in lane and lane["label"], f"preset {name}: lane missing label"
            # Every piece-id referenced must exist in PIECES — presets aren't
            # the place to invent new piece-ids.
            for pid in lane["pieces"]:
                assert pid in drums.PIECES, f"preset {name}: unknown piece-id {pid}"


def test_phaseshift8_preserves_legacy_lane_order():
    """The phase_shift_8 preset must match the v3 drums plugin's HH/Sn/T1/T2/
    T3/Cr/Ri/Ki order so existing sloppaks keep their familiar layout."""
    labels = [lane["label"] for lane in drums.PRESET_PHASESHIFT8]
    assert labels == ["HH", "Sn", "T1", "T2", "T3", "Cr", "Ri", "Ki"]


def test_every_piece_is_routed_by_every_preset():
    """Adding a new piece-id to PIECES without routing it in every preset
    would let hits for that piece vanish from the highway under that
    preset. This guards against that class of regression."""
    all_pieces = set(drums.PIECES.keys())
    for name, preset in drums.PRESETS.items():
        routed = {pid for lane in preset for pid in lane["pieces"]}
        missing = all_pieces - routed
        assert not missing, (
            f"preset {name!r} doesn't route: {sorted(missing)}. "
            f"Add them to an existing lane (grouped presets) or to their "
            f"own lane (e-kit presets).")


# ── Wire helpers ─────────────────────────────────────────────────────────────

def test_validate_drum_tab_accepts_minimal_payload():
    ok, reason = drums.validate_drum_tab({"hits": []})
    assert ok, reason


def test_validate_drum_tab_rejects_non_object():
    ok, _ = drums.validate_drum_tab([])
    assert not ok
    ok, _ = drums.validate_drum_tab(None)
    assert not ok


def test_validate_drum_tab_rejects_missing_hits():
    ok, reason = drums.validate_drum_tab({"version": 1})
    assert not ok
    assert "hits" in reason


def test_validate_drum_tab_rejects_non_int_version():
    ok, _ = drums.validate_drum_tab({"version": "1", "hits": []})
    assert not ok


def test_validate_drum_tab_accepts_unknown_version():
    """An unknown schema version is logged but the payload is still accepted —
    forward-compat per Principle IV (backwards-compatible custom song library)."""
    ok, _ = drums.validate_drum_tab({"version": 99, "hits": []})
    assert ok


def test_hit_to_wire_canonicalises_known_fields():
    out = drums.hit_to_wire({"t": 0.5001, "p": "kick", "v": 110, "g": True, "f": False, "k": 0.08})
    assert out == {"t": 0.5, "p": "kick", "v": 110, "g": True, "k": 0.08}


def test_hit_to_wire_drops_malformed():
    assert drums.hit_to_wire({"t": 0.5}) is None         # no piece
    assert drums.hit_to_wire({"p": "kick"}) is None       # no time
    assert drums.hit_to_wire({"t": "x", "p": "kick"}) is None
    assert drums.hit_to_wire({"t": 0.5, "p": ""}) is None
    assert drums.hit_to_wire("not a dict") is None


def test_hit_to_wire_drops_out_of_range_velocity():
    """Velocity must be 1-127. 0 / 200 / negative get silently dropped."""
    out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": 0})
    assert "v" not in out
    out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": 200})
    assert "v" not in out
    out = drums.hit_to_wire({"t": 0.5, "p": "kick", "v": -1})
    assert "v" not in out


def test_hits_to_wire_drops_bad_and_sorts():
    hits = [
        {"t": 2.0, "p": "snare"},
        {"t": 1.0, "p": "kick"},
        {"t": "bad", "p": "kick"},   # dropped
        {"t": 0.5, "p": "ride", "v": 90},
    ]
    out = drums.hits_to_wire(hits)
    assert [h["t"] for h in out] == [0.5, 1.0, 2.0]
    assert [h["p"] for h in out] == ["ride", "kick", "snare"]


def test_normalise_kit_dedupes_and_fills_names():
    kit = [
        {"id": "kick", "name": "Kick"},
        {"id": "snare"},                          # name derived from id
        {"id": "kick", "name": "duplicate"},      # dedupe wins by first occurrence
        {"id": "", "name": "empty id"},           # dropped
        "not a dict",                              # dropped
    ]
    out = drums.normalise_kit(kit)
    assert out == [{"id": "kick", "name": "Kick"}, {"id": "snare", "name": "Snare"}]


def test_normalise_kit_handles_none():
    assert drums.normalise_kit(None) == []
    assert drums.normalise_kit("not a list") == []
