"""Tests for lib/midi_import.py — list_midi_tracks and convert_midi_track_to_keys_wire.

Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path so
the helpers can be exercised without shipping fixture .mid files.

Covers:
  - format-0 per-channel splitting
  - drum-channel (GM channel 9) filtering
  - format-1 no-split (multi-channel track stays merged)
  - tempo-change-aware tick→seconds conversion
  - CC64 sustain pedal note extension
  - same-pitch retrigger (FIFO stacking — no dropped notes)
  - pitch encoding: s = pitch // 24, f = pitch % 24
"""

import pytest
import mido

from midi_import import list_midi_tracks, convert_midi_track_to_keys_wire


# ── helpers ───────────────────────────────────────────────────────────────────

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


# ── list_midi_tracks ──────────────────────────────────────────────────────────

def test_format0_splits_into_per_channel_entries(tmp_path):
    """Format-0 with two non-drum channels → two separate picker entries."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # Channel 0: piano (program 0)
    track.append(mido.Message("program_change", channel=0, program=0, time=0))
    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))
    # Channel 1: bass (program 33)
    track.append(mido.Message("program_change", channel=1, program=33, time=0))
    track.append(mido.Message("note_on",  channel=1, note=48, velocity=64, time=0))
    track.append(mido.Message("note_off", channel=1, note=48, velocity=0,  time=480))

    tracks = list_midi_tracks(_save(mid, tmp_path))
    assert len(tracks) == 2
    channels = {t["channel"] for t in tracks}
    assert channels == {0, 1}
    # Each entry should carry a channel_filter for isolation
    assert all(t["channel_filter"] is not None for t in tracks)


def test_format0_drum_channel_excluded(tmp_path):
    """Channel-9 notes are not returned, even in format-0 split mode."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # Melodic channel
    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))
    # Drum channel — must be filtered out
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=64, time=0))
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,  time=480))

    tracks = list_midi_tracks(_save(mid, tmp_path))
    assert len(tracks) == 1
    assert tracks[0]["channel"] == 0
    assert all(t["channel"] != 9 for t in tracks)


def test_format0_drums_only_returns_empty(tmp_path):
    """A format-0 file with only channel-9 notes yields an empty list."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    track.append(mido.Message("note_on",  channel=9, note=36, velocity=64, time=0))
    track.append(mido.Message("note_off", channel=9, note=36, velocity=0,  time=480))

    tracks = list_midi_tracks(_save(mid, tmp_path))
    assert tracks == []


def test_format1_multi_channel_not_split(tmp_path):
    """Format-1 tracks are not split per-channel — the whole track is one entry."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    # Conductor track (required for type-1)
    conductor = mido.MidiTrack()
    mid.tracks.append(conductor)
    conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
    # Multi-channel note track
    note_track = mido.MidiTrack()
    mid.tracks.append(note_track)
    note_track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=480))
    note_track.append(mido.Message("note_on",  channel=1, note=48, velocity=64, time=0))
    note_track.append(mido.Message("note_off", channel=1, note=48, velocity=0,  time=480))

    tracks = list_midi_tracks(_save(mid, tmp_path))
    # Conductor track has no melodic notes → skipped; note track → 1 merged entry
    assert len(tracks) == 1
    assert tracks[0]["channel_filter"] is None


def test_piano_program_flagged(tmp_path):
    """Tracks with a piano-family program are flagged is_piano=True."""
    mid = mido.MidiFile(type=1, ticks_per_beat=480)
    conductor = mido.MidiTrack()
    mid.tracks.append(conductor)
    note_track = mido.MidiTrack()
    mid.tracks.append(note_track)
    note_track.append(mido.MetaMessage("track_name", name="Piano", time=0))
    note_track.append(mido.Message("program_change", channel=0, program=0, time=0))
    note_track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=480))

    tracks = list_midi_tracks(_save(mid, tmp_path))
    assert len(tracks) == 1
    assert tracks[0]["is_piano"] is True


# ── convert_midi_track_to_keys_wire ──────────────────────────────────────────

def test_pitch_encoding(tmp_path):
    """MIDI pitch is encoded as s = pitch // 24, f = pitch % 24."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # Middle C = MIDI 60 → s=2, f=12
    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))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
    assert len(result["notes"]) == 1
    n = result["notes"][0]
    assert n["s"] == 60 // 24
    assert n["f"] == 60 % 24
    # Reconstruct: s*24 + f should recover original MIDI pitch
    assert n["s"] * 24 + n["f"] == 60


def test_constant_tempo_timing(tmp_path):
    """At 120 BPM with 480 ticks/beat, one beat = 0.5 s."""
    tpb = 480
    mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # One-beat note starting at tick 0
    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=tpb))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
    assert len(result["notes"]) == 1
    n = result["notes"][0]
    assert n["t"] == pytest.approx(0.0)
    assert n["sus"] == pytest.approx(0.5)


def test_tempo_change_boundary(tmp_path):
    """Notes crossing a tempo change get correct durations on each side."""
    tpb = 480
    mid = mido.MidiFile(type=1, ticks_per_beat=tpb)
    # Conductor: 120 BPM → switch to 60 BPM at tick 480
    conductor = mido.MidiTrack()
    mid.tracks.append(conductor)
    conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))     # 120 BPM
    conductor.append(mido.MetaMessage("set_tempo", tempo=1000000, time=tpb))  # 60 BPM

    note_track = mido.MidiTrack()
    mid.tracks.append(note_track)
    # Note before tempo change: starts tick 0, ends tick 480 → 0.5 s
    note_track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    note_track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=tpb))
    # Note after tempo change: starts tick 480, ends tick 960 → 1.0 s
    note_track.append(mido.Message("note_on",  channel=0, note=62, velocity=64, time=0))
    note_track.append(mido.Message("note_off", channel=0, note=62, velocity=0,  time=tpb))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=1)
    notes = sorted(result["notes"], key=lambda n: n["t"])
    assert len(notes) == 2
    assert notes[0]["t"] == pytest.approx(0.0)
    assert notes[0]["sus"] == pytest.approx(0.5)   # 1 beat at 120 BPM
    assert notes[1]["t"] == pytest.approx(0.5)
    assert notes[1]["sus"] == pytest.approx(1.0)   # 1 beat at 60 BPM


def test_cc64_sustain_extends_note(tmp_path):
    """While the sustain pedal is held, note-off defers end time to pedal-up."""
    tpb = 480
    mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # Pedal down at tick 0
    track.append(mido.Message("control_change", channel=0, control=64, value=64, time=0))
    # Note starts at tick 0
    track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    # Key released at tick 240 (while pedal still held)
    track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=240))
    # Pedal up at tick 480 → note should end here (0.5 s total duration)
    track.append(mido.Message("control_change", channel=0, control=64, value=0, time=240))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
    assert len(result["notes"]) == 1
    n = result["notes"][0]
    assert n["t"] == pytest.approx(0.0)
    assert n["sus"] == pytest.approx(0.5)  # extended to pedal-up at tick 480


def test_same_pitch_retrigger(tmp_path):
    """Rapid same-pitch retrigger emits two separate notes (FIFO stack)."""
    tpb = 480
    mid = mido.MidiFile(type=0, ticks_per_beat=tpb)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # First note on
    track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=0))
    # Retrigger: second note_on before first note_off
    track.append(mido.Message("note_on",  channel=0, note=60, velocity=64, time=240))
    # First note_off (FIFO: closes the first note_on at tick 0)
    track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=240))
    # Second note_off
    track.append(mido.Message("note_off", channel=0, note=60, velocity=0,  time=240))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0)
    assert len(result["notes"]) == 2
    notes = sorted(result["notes"], key=lambda n: n["t"])
    assert notes[0]["t"] == pytest.approx(0.0)
    # Duration: from tick 0 to first note_off at tick 480 (cumulative)
    assert notes[0]["sus"] == pytest.approx(0.5, abs=0.01)
    assert notes[1]["t"] == pytest.approx(0.25)


def test_channel_filter_isolates_channel(tmp_path):
    """channel_filter restricts conversion to the specified channel only."""
    mid = mido.MidiFile(type=0, ticks_per_beat=480)
    track = mido.MidiTrack()
    mid.tracks.append(track)
    # Ch 0: pitch 60
    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))
    # Ch 1: pitch 72
    track.append(mido.Message("note_on",  channel=1, note=72, velocity=64, time=0))
    track.append(mido.Message("note_off", channel=1, note=72, velocity=0,  time=480))

    path = _save(mid, tmp_path)
    result = convert_midi_track_to_keys_wire(path, track_index=0, channel_filter=0)
    pitches = {n["s"] * 24 + n["f"] for n in result["notes"]}
    assert 60 in pitches
    assert 72 not in pitches


def test_audio_offset_applied(tmp_path):
    """audio_offset shifts all note start times by the given amount."""
    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))

    path = _save(mid, tmp_path)
    result = convert_midi_track_to_keys_wire(path, track_index=0, audio_offset=1.5)
    assert result["notes"][0]["t"] == pytest.approx(1.5)


def test_wire_format_shape(tmp_path):
    """Returned dict has all required sloppak arrangement keys."""
    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))

    result = convert_midi_track_to_keys_wire(_save(mid, tmp_path), track_index=0, name="Keys")
    assert result["name"] == "Keys"
    assert "notes" in result
    assert "chords" in result
    assert "anchors" in result
    assert "tuning" in result
    assert "capo" in result
