"""Tests for `list_drum_tracks` and `convert_drum_track_from_midi`.

Builds synthetic MIDI files in-memory (no fixtures on disk) and exercises
the drum-tab extraction: channel-9 listing, piece resolution, ghost-velocity
flag, flam-window collapse, choke-window note-off, and tempo-aware timing.
"""

from __future__ import annotations

import mido

from midi_import import list_drum_tracks, convert_drum_track_from_midi


def _save(mid: mido.MidiFile, tmp_path, name: str = "drums.mid") -> str:
    p = tmp_path / name
    mid.save(str(p))
    return str(p)


# ── list_drum_tracks ──────────────────────────────────────────────────────────

def test_list_drum_tracks_skips_tracks_without_channel9(tmp_path):
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    piano = mido.MidiTrack()
    mid.tracks.append(piano)
    piano.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    piano.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
    assert list_drum_tracks(_save(mid, tmp_path)) == []


def test_list_drum_tracks_returns_channel9_entry(tmp_path):
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("track_name", name="Drums", time=0))
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=100, time=0))
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=92, time=0))
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0, time=120))
    tracks = list_drum_tracks(_save(mid, tmp_path))
    assert len(tracks) == 1
    t = tracks[0]
    assert t["channel"] == 9
    assert t["channel_filter"] == 9
    assert t["is_drums"] is True
    assert t["notes"] == 2
    assert t["name"] == "Drums"


def test_list_drum_tracks_surfaces_format0_mixed_track(tmp_path):
    """A format-0 track containing both piano (ch0) and drum (ch9) events
    must surface once — list_midi_tracks already handles the piano half."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=100, time=0))
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=120))
    drums = list_drum_tracks(_save(mid, tmp_path))
    assert len(drums) == 1
    assert drums[0]["notes"] == 1


# ── convert_drum_track_from_midi ──────────────────────────────────────────────

def test_basic_conversion_piece_and_velocity(tmp_path):
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))  # 120 BPM
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=110, time=0))     # kick @ 0s
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,  time=240))    # 0.25s
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=92,  time=240))   # snare @ 0.5s
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=240))

    out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)
    assert out["version"] == 1
    hits = out["hits"]
    assert [(h["t"], h["p"], h["v"]) for h in hits] == [
        (0.0, "kick", 110),
        (0.5, "snare", 92),
    ]
    assert {k["id"] for k in out["kit"]} == {"kick", "snare"}


def test_ghost_velocity_flag(tmp_path):
    """Velocity below 40 marks the hit as a ghost note."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=30, time=0))
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=120))
    hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
    assert hits[0].get("g") is True


def test_flam_collapse_within_30ms(tmp_path):
    """Two snare hits 20 ms apart collapse into one flam — the louder
    survives, marked `f: true`."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))  # 120 BPM, 480 tpqn → 1 tick = 1.0416ms
    # ghost lead-in at t=0 (≈10 ticks ≈ 0.01s would be too small; use 20ms = ~19 ticks),
    # then main hit at t≈20ms after that.
    # 480 tpqn at 120 BPM → 1 tick ≈ 1.0417 ms.  20 ms ≈ 19 ticks.
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=30, time=0))    # ghost
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=5))
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=110, time=10))  # main, 15 ticks after ghost onset → ~15.6ms (1 tick ≈ 1.04ms at 120BPM/480tpqn)
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=120))
    out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
    assert len(out) == 1, f"expected flam collapse, got {out}"
    assert out[0]["p"] == "snare"
    assert out[0]["v"] == 110
    assert out[0].get("f") is True


def test_flam_window_does_not_collapse_unrelated_pieces(tmp_path):
    """Snare + kick 10 ms apart must NOT collapse — flams only apply to the
    same piece (drummers don't flam across pieces)."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=100, time=0))   # snare
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=5))
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=100, time=5))   # kick
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,  time=120))
    hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
    assert {h["p"] for h in hits} == {"snare", "kick"}
    assert all("f" not in h for h in hits)


def test_choke_on_short_cymbal_note(tmp_path):
    """A crash with a note-off arriving 80 ms after note-on is treated as a
    cymbal choke; non-cymbal pieces don't get a `k` field even for short
    durations."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))  # 120 BPM → 1 tick = 1.0417ms
    # Crash @0, off ~80ms (=76 ticks)
    track.append(mido.Message("note_on",  channel=9, note=49, velocity=120, time=0))
    track.append(mido.Message("note_off", channel=9, note=49, velocity=0,  time=77))
    # Snare @t≈0.1s with a short note-off (must not produce k).
    track.append(mido.Message("note_on",  channel=9, note=38, velocity=100, time=0))
    track.append(mido.Message("note_off", channel=9, note=38, velocity=0,  time=50))
    out = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
    crash = next(h for h in out if h["p"] == "crash_l")
    snare = next(h for h in out if h["p"] == "snare")
    assert "k" in crash and 0.0 < crash["k"] <= 0.12
    assert "k" not in snare


def test_choke_window_does_not_fire_for_long_cymbal_sustain(tmp_path):
    """A crash held for 500 ms is a regular open ring, not a choke."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    # 500ms ≈ 480 ticks at 120 BPM
    track.append(mido.Message("note_on",  channel=9, note=49, velocity=120, time=0))
    track.append(mido.Message("note_off", channel=9, note=49, velocity=0,  time=480))
    hit = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"][0]
    assert "k" not in hit


def test_unmapped_drum_note_skipped(tmp_path):
    """Cowbell (MIDI 56) isn't in the vocab — must be skipped."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    track.append(mido.Message("note_on",  channel=9, note=56, velocity=100, time=0))   # cowbell — drop
    track.append(mido.Message("note_off", channel=9, note=56, velocity=0,  time=240))
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=100, time=240))  # kick — keep
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,  time=120))
    hits = convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]
    assert len(hits) == 1
    assert hits[0]["p"] == "kick"


def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
    """Opting in via out_unmapped records the dropped MIDI notes (count +
    times) so a caller can surface a warning / mapping UI."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    track.append(mido.Message("note_on",  channel=9, note=56, velocity=100, time=0))    # cowbell — drop
    track.append(mido.Message("note_off", channel=9, note=56, velocity=0,   time=240))
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=100, time=0))    # kick — keep
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,   time=240))
    track.append(mido.Message("note_on",  channel=9, note=54, velocity=100, time=0))    # tambourine — drop
    track.append(mido.Message("note_off", channel=9, note=54, velocity=0,   time=240))
    track.append(mido.Message("note_on",  channel=9, note=56, velocity=100, time=0))    # cowbell again — drop
    track.append(mido.Message("note_off", channel=9, note=56, velocity=0,   time=240))

    unmapped: dict[int, dict] = {}
    hits = convert_drum_track_from_midi(
        _save(mid, tmp_path), 0, out_unmapped=unmapped)["hits"]
    assert [h["p"] for h in hits] == ["kick"]
    assert set(unmapped.keys()) == {56, 54}
    assert unmapped[56]["count"] == 2
    assert unmapped[54]["count"] == 1
    # Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
    assert all(isinstance(t, float) for t in unmapped[56]["times"])
    assert len(unmapped[56]["times"]) == 2


def test_non_channel9_events_ignored(tmp_path):
    """A note_on on channel 0 with MIDI 36 is NOT a kick — the converter
    must scope to channel 9."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    track.append(mido.Message("note_on",  channel=0, note=36, velocity=100, time=0))
    track.append(mido.Message("note_off", channel=0, note=36, velocity=0,  time=240))
    assert convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"] == []


def test_hihat_openness_preserved(tmp_path):
    """Three hi-hat MIDI notes round-trip as three distinct piece-ids — the
    drums plugin's hit detector relies on this to reject closed-hat strikes
    on open-hat notes."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    for offset, midi_n in enumerate((42, 46, 44)):
        track.append(mido.Message("note_on",  channel=9, note=midi_n, velocity=80,
                                  time=480 if offset else 0))
        track.append(mido.Message("note_off", channel=9, note=midi_n, velocity=0, time=120))
    pids = [h["p"] for h in convert_drum_track_from_midi(_save(mid, tmp_path), 0)["hits"]]
    assert pids == ["hh_closed", "hh_open", "hh_pedal"]
