"""Tests for the preset and bank management system.

Covers all acceptance criteria:
- Save/load presets to/from JSON (human-readable)
- Bank structure with 4 presets per bank
- Footswitch preset-up/down wraps within bank
- Bank-up/down switches banks
- MIDI Program Change loads preset (bank=channel, program=preset)
- Current preset saved on change (auto-restore on power cycle)
- Factory preset installation
- Preset rename and reorder
"""

from __future__ import annotations

import json
import shutil
import tempfile
from pathlib import Path

import pytest

from src.presets.manager import (
    FACTORY_PRESET_DIR,
    PRESETS_PER_BANK,
    PresetManager,
    _preset_from_dict,
    _preset_to_dict,
)
from src.presets.types import Channel, FXBlock, FXType, MIDIMapping, Preset


# ── Fixtures ────────────────────────────────────────────────────────────────


@pytest.fixture
def preset_dir() -> Path:
    """Provide a clean temp directory for each test."""
    tmp = Path(tempfile.mkdtemp(prefix="pedal_presets_"))
    yield tmp
    shutil.rmtree(tmp, ignore_errors=True)


@pytest.fixture
def manager(preset_dir: Path) -> PresetManager:
    """Return a PresetManager pointed at the temp dir, no audio pipeline."""
    return PresetManager(preset_dir=str(preset_dir), audio_pipeline=None)


@pytest.fixture
def sample_preset() -> Preset:
    """A realistic preset with a few FX blocks and MIDI mappings."""
    return Preset(
        name="Crunch Rhythm",
        bank=0,
        program=0,
        chain=[
            FXBlock(FXType.NOISE_GATE, enabled=True, params={"threshold": 0.01}),
            FXBlock(FXType.OVERDRIVE, enabled=True, params={"drive": 0.6, "gain": 0.8}),
            FXBlock(FXType.NAM_AMP, enabled=True, nam_model_path="/home/pedal/models/marshall.nam"),
            FXBlock(FXType.IR_CAB, enabled=True, ir_file_path="/home/pedal/irs/mesa_v30.wav"),
            FXBlock(FXType.CHORUS, enabled=True, params={"rate": 0.4, "depth": 0.6, "mix": 0.3}),
            FXBlock(FXType.DELAY, enabled=True, params={"time": 420.0, "feedback": 0.35, "mix": 0.25}),
            FXBlock(FXType.REVERB, enabled=True, params={"decay": 0.5, "mix": 0.2}),
            FXBlock(FXType.VOLUME, enabled=True, params={"level": 0.85}),
        ],
        midi_mappings={
            "drive": MIDIMapping(cc_number=14, channel=0, min_val=0.0, max_val=1.0),
            "mix": MIDIMapping(cc_number=15, channel=0, min_val=0.0, max_val=1.0),
        },
        master_volume=0.8,
    )


@pytest.fixture
def factory_preset_dir(preset_dir: Path) -> Path:
    """Create a fake factory preset directory structure."""
    factory = preset_dir / "factory_presets"
    bank0 = factory / "bank_0"
    bank0.mkdir(parents=True)
    (bank0 / "bank.json").write_text(
        json.dumps({"number": 0, "name": "Factory Classics", "preset_count": 4}), encoding="utf-8"
    )
    for i in range(3):
        p = Preset(name=f"Factory {i}", bank=0, program=i, master_volume=0.75)
        (bank0 / f"preset_{i}.json").write_text(
            json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
        )
    bank1 = factory / "bank_1"
    bank1.mkdir(parents=True)
    (bank1 / "bank.json").write_text(
        json.dumps({"number": 1, "name": "Modern High-Gain", "preset_count": 4}), encoding="utf-8"
    )
    p = Preset(name="Chug", bank=1, program=0, master_volume=0.7)
    (bank1 / "preset_0.json").write_text(
        json.dumps(_preset_to_dict(p), indent=2), encoding="utf-8"
    )
    return factory


# ── Serialisation round-trip ────────────────────────────────────────────────


class TestSerialization:
    def test_preset_to_dict_round_trip(self, sample_preset: Preset):
        d = _preset_to_dict(sample_preset)
        restored = _preset_from_dict(d)
        assert restored.name == sample_preset.name
        assert restored.bank == sample_preset.bank
        assert restored.program == sample_preset.program
        assert restored.master_volume == sample_preset.master_volume
        assert len(restored.chain) == len(sample_preset.chain)
        for a, b in zip(restored.chain, sample_preset.chain):
            assert a.fx_type == b.fx_type
            assert a.params == b.params
            assert a.nam_model_path == b.nam_model_path
            assert a.ir_file_path == b.ir_file_path
        assert set(restored.midi_mappings.keys()) == set(sample_preset.midi_mappings.keys())
        for k in sample_preset.midi_mappings:
            assert restored.midi_mappings[k].cc_number == sample_preset.midi_mappings[k].cc_number

    def test_to_dict_is_human_readable(self, sample_preset: Preset):
        d = _preset_to_dict(sample_preset)
        serialized = json.dumps(d, indent=2, sort_keys=True)
        # Verify key strings are present plain-text, not encoded
        assert "Crunch Rhythm" in serialized
        assert "marshall.nam" in serialized
        assert "mesa_v30" in serialized
        assert "noise_gate" in serialized
        assert "overdrive" in serialized

    def test_minimal_preset_round_trip(self):
        p = Preset(name="Empty", bank=0, program=0)
        d = _preset_to_dict(p)
        restored = _preset_from_dict(d)
        assert restored.name == "Empty"
        assert restored.chain == []
        assert restored.midi_mappings == {}
        assert restored.master_volume == 0.8


# ── Save / Load ─────────────────────────────────────────────────────────────


class TestSaveLoad:
    def test_save_and_load(self, manager: PresetManager, sample_preset: Preset):
        manager.save(sample_preset)
        loaded = manager.load(0, 0)
        assert loaded.name == "Crunch Rhythm"
        assert len(loaded.chain) == 8
        assert loaded.chain[0].fx_type == FXType.NOISE_GATE

    def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
        manager.save(sample_preset)
        path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
        assert path.exists()
        content = path.read_text(encoding="utf-8")
        assert "Crunch Rhythm" in content
        # Confirm it's valid JSON
        data = json.loads(content)
        assert data["name"] == "Crunch Rhythm"

    def test_load_missing_preset_raises(self, manager: PresetManager):
        with pytest.raises(FileNotFoundError):
            manager.load(9, 3)

    def test_save_multiple_presets(self, manager: PresetManager):
        p0 = Preset(name="A", bank=0, program=0)
        p1 = Preset(name="B", bank=0, program=1)
        p2 = Preset(name="C", bank=0, program=2)
        p3 = Preset(name="D", bank=0, program=3)
        for p in [p0, p1, p2, p3]:
            manager.save(p)
        assert manager.load(0, 0).name == "A"
        assert manager.load(0, 1).name == "B"
        assert manager.load(0, 2).name == "C"
        assert manager.load(0, 3).name == "D"


# ── Bank structure ──────────────────────────────────────────────────────────


class TestBanks:
    def test_list_banks_empty(self, manager: PresetManager):
        assert manager.list_banks() == []

    def test_list_banks_after_saves(self, manager: PresetManager):
        p0 = Preset(name="A", bank=0, program=0)
        p1 = Preset(name="B", bank=1, program=0)
        manager.save(p0)
        manager.save(p1)
        banks = manager.list_banks()
        assert len(banks) == 2
        assert banks[0].number == 0
        assert banks[1].number == 1

    def test_bank_has_four_preset_slots(self, manager: PresetManager):
        """The PRESETS_PER_BANK convention is set to 4."""
        assert PRESETS_PER_BANK == 4

    def test_program_clamped_to_range(self, manager: PresetManager):
        """select() clamps program to 0-3."""
        p = manager.select(0, -1)
        assert p.program == 0
        p = manager.select(0, 99)
        assert p.program == 3


# ── Footswitch navigation ───────────────────────────────────────────────────


class TestFootswitchNavigation:
    def test_preset_up_within_bank(self, manager: PresetManager):
        p0 = Preset(name="P0", bank=0, program=0)
        p1 = Preset(name="P1", bank=0, program=1)
        p2 = Preset(name="P2", bank=0, program=2)
        p3 = Preset(name="P3", bank=0, program=3)
        for p in [p0, p1, p2, p3]:
            manager.save(p)
        manager.select(0, 0)
        assert manager.preset_up().name == "P1"
        assert manager.preset_up().name == "P2"
        assert manager.preset_up().name == "P3"
        # Wraps 3 -> 0
        assert manager.preset_up().name == "P0"

    def test_preset_down_within_bank(self, manager: PresetManager):
        p0 = Preset(name="P0", bank=0, program=0)
        p1 = Preset(name="P1", bank=0, program=1)
        p2 = Preset(name="P2", bank=0, program=2)
        for p in [p0, p1, p2]:
            manager.save(p)
        manager.select(0, 0)
        # Wraps 0 -> 3 (which is empty, so "New Bank 0 #3")
        assert "New" in manager.preset_down().name
        # Down again -> 2
        assert manager.preset_down().name == "P2"

    def test_preset_up_creates_empty_preset_when_missing(self, manager: PresetManager):
        """When pressing up into an empty slot, a blank preset is created."""
        p0 = Preset(name="Only One", bank=0, program=0)
        manager.save(p0)
        manager.select(0, 0)
        next_preset = manager.preset_up()
        assert next_preset.name == "New Bank 0 #1"
        assert next_preset.bank == 0
        assert next_preset.program == 1


# ── Bank up/down ────────────────────────────────────────────────────────────


class TestBankUpDown:
    def test_bank_up(self, manager: PresetManager):
        """Moving from bank 0 to bank 1."""
        manager.save(Preset(name="B0P0", bank=0, program=0))
        manager.save(Preset(name="B0P1", bank=0, program=1))
        manager.save(Preset(name="B1P0", bank=1, program=0))
        manager.select(0, 0)
        bank, preset = manager.bank_up()
        assert bank.number == 1
        assert preset.name == "B1P0"

    def test_bank_down(self, manager: PresetManager):
        manager.save(Preset(name="B0P0", bank=0, program=0))
        manager.save(Preset(name="B1P0", bank=1, program=0))
        manager.select(1, 0)
        bank, preset = manager.bank_down()
        assert bank.number == 0
        assert preset.name == "B0P0"

    def test_bank_up_wraps(self, manager: PresetManager):
        """Wrapping from highest bank back to lowest."""
        manager.save(Preset(name="B0P0", bank=0, program=0))
        manager.save(Preset(name="B1P0", bank=1, program=0))
        manager.select(1, 0)
        bank, preset = manager.bank_up()
        assert bank.number == 0

    def test_bank_down_wraps(self, manager: PresetManager):
        manager.save(Preset(name="B0P0", bank=0, program=0))
        manager.save(Preset(name="B1P0", bank=1, program=0))
        manager.select(0, 0)
        bank, preset = manager.bank_down()
        assert bank.number == 1


# ── MIDI Program Change ─────────────────────────────────────────────────────


class TestMIDIPC:
    def test_midi_pc_selects_preset(self, manager: PresetManager):
        """Bank=channel (0), program=preset (2)."""
        p0 = Preset(name="P0", bank=0, program=0)
        p1 = Preset(name="P1", bank=0, program=1)
        p2 = Preset(name="P2", bank=0, program=2)
        for p in [p0, p1, p2]:
            manager.save(p)
        result = manager.midi_pc(channel=0, program=2)
        assert result.name == "P2"
        assert manager.current_bank == 0
        assert manager.current_program == 2

    def test_midi_pc_different_banks(self, manager: PresetManager):
        """Channel maps to bank, program maps to preset index."""
        manager.save(Preset(name="B1P0", bank=1, program=0))
        manager.save(Preset(name="B1P3", bank=1, program=3))
        result = manager.midi_pc(channel=1, program=3)
        assert result.name == "B1P3"

    def test_midi_pc_creates_blank_on_empty_slot(self, manager: PresetManager):
        """When PC targets an empty slot, a blank preset is created."""
        manager.save(Preset(name="B2P0", bank=2, program=0))
        result = manager.midi_pc(channel=2, program=2)
        assert "New" in result.name
        assert result.program == 2


# ── Auto-restore ────────────────────────────────────────────────────────────


class TestAutoRestore:
    def test_save_state_persists(self, manager: PresetManager, preset_dir: Path):
        manager.save(Preset(name="RestoreMe", bank=0, program=2))
        manager.select(0, 2)  # This calls save_state internally
        state_path = preset_dir / "state.json"
        assert state_path.exists()
        data = json.loads(state_path.read_text(encoding="utf-8"))
        assert data["current_bank"] == 0
        assert data["current_program"] == 2

    def test_restore_state(self, manager: PresetManager, preset_dir: Path):
        manager.save(Preset(name="RestoreMe", bank=0, program=2))
        manager.select(0, 2)

        # Create a fresh manager and restore
        m2 = PresetManager(preset_dir=str(preset_dir))
        preset = m2.restore_state()
        assert preset is not None
        assert preset.name == "RestoreMe"
        assert m2.current_bank == 0
        assert m2.current_program == 2

    def test_restore_state_no_file(self, manager: PresetManager):
        """restore_state returns None when no state file exists."""
        assert manager.restore_state() is None

    def test_restore_state_missing_preset_fallback(self, preset_dir: Path):
        """If the saved slot is empty, fall back gracefully."""
        # Write state pointing to a slot that doesn't exist yet
        state_path = preset_dir / "state.json"
        state_path.write_text(
            json.dumps({"current_bank": 5, "current_program": 3}), encoding="utf-8"
        )
        m = PresetManager(preset_dir=str(preset_dir))
        assert m.restore_state() is None

    def test_preset_change_triggers_state_save(self, manager: PresetManager, preset_dir: Path):
        """Every preset_up/down/select saves state."""
        manager.save(Preset(name="A", bank=0, program=0))
        manager.save(Preset(name="B", bank=0, program=1))
        manager.select(0, 0)
        manager.preset_up()
        state = json.loads((preset_dir / "state.json").read_text())
        assert state["current_program"] == 1


# ── Preset rename and reorder ───────────────────────────────────────────────


class TestRenameReorder:
    def test_rename(self, manager: PresetManager):
        manager.save(Preset(name="Old", bank=0, program=0))
        renamed = manager.rename(0, 0, "New")
        assert renamed.name == "New"
        assert manager.load(0, 0).name == "New"

    def test_reorder_same_slot_noop(self, manager: PresetManager):
        """Reordering to the same program index does nothing."""
        manager.save(Preset(name="A", bank=0, program=0))
        manager.reorder(0, 0, 0)  # Should not raise
        assert manager.load(0, 0).name == "A"

    def test_reorder_swap(self, manager: PresetManager):
        """Reordering from 0 to 2 swaps if slot 2 is occupied."""
        manager.save(Preset(name="A", bank=0, program=0))
        manager.save(Preset(name="B", bank=0, program=2))
        manager.reorder(0, 0, 2)
        assert manager.load(0, 0).name == "B"
        assert manager.load(0, 2).name == "A"

    def test_reorder_into_empty_slot(self, manager: PresetManager):
        manager.save(Preset(name="A", bank=0, program=0))
        manager.reorder(0, 0, 3)
        assert manager.load(0, 3).name == "A"

    def test_reorder_missing_source_raises(self, manager: PresetManager):
        with pytest.raises(FileNotFoundError):
            manager.reorder(0, 0, 1)

    def test_reorder_out_of_range_raises(self, manager: PresetManager):
        manager.save(Preset(name="A", bank=0, program=0))
        with pytest.raises(ValueError):
            manager.reorder(0, 0, 4)

    def test_reorder_updates_active_tracking(self, manager: PresetManager):
        """If the active preset is moved, current_program tracks it."""
        manager.save(Preset(name="A", bank=0, program=0))
        manager.save(Preset(name="B", bank=1, program=0))
        manager.select(0, 0)
        manager.reorder(0, 0, 2)
        assert manager.current_program == 2  # active moved to slot 2


# ── Factory presets ─────────────────────────────────────────────────────────


class TestFactoryPresets:
    def test_install_factory_presets_overwrite_default(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
        monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
        count = manager.install_factory_presets()
        assert count == 4  # 3 in bank 0 + 1 in bank 1
        assert manager.load(0, 0).name == "Factory 0"
        assert manager.load(0, 1).name == "Factory 1"
        assert manager.load(1, 0).name == "Chug"

    def test_install_skip_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
        monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
        manager.save(Preset(name="My Custom", bank=0, program=0))
        count = manager.install_factory_presets(overwrite=False)
        # Should install 3 (skip slot 0 in bank 0, install 1 & 2, plus bank 1)
        assert count == 3
        assert manager.load(0, 0).name == "My Custom"  # Not overwritten

    def test_install_overwrite_existing(self, manager: PresetManager, factory_preset_dir: Path, monkeypatch):
        monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", factory_preset_dir)
        manager.save(Preset(name="My Custom", bank=0, program=0))
        count = manager.install_factory_presets(overwrite=True)
        assert count == 4
        assert manager.load(0, 0).name == "Factory 0"  # Overwritten

    def test_no_factory_dir(self, manager: PresetManager, monkeypatch):
        """When factory dir doesn't exist, returns 0."""
        monkeypatch.setattr("src.presets.manager.FACTORY_PRESET_DIR", Path("/nonexistent_factory"))
        assert manager.install_factory_presets() == 0


# ── Activation pipeline integration ─────────────────────────────────────────


class TestActivation:
    def test_activate_calls_pipeline(self, manager: PresetManager, sample_preset: Preset, mocker):
        pipeline = mocker.Mock()
        manager._pipeline = pipeline
        manager.activate(sample_preset)
        pipeline.load_preset.assert_called_once_with(sample_preset)

    def test_select_calls_activate(self, manager: PresetManager, mocker):
        pipeline = mocker.Mock()
        manager._pipeline = pipeline
        manager.save(Preset(name="ActivateMe", bank=0, program=0))
        preset = manager.select(0, 0)
        assert preset.name == "ActivateMe"
        pipeline.load_preset.assert_called_once()

    def test_select_no_pipeline_does_not_crash(self, manager: PresetManager):
        """Works without an audio pipeline attached."""
        manager.save(Preset(name="Headless", bank=0, program=0))
        preset = manager.select(0, 0)
        assert preset.name == "Headless"


# ── Edge cases ──────────────────────────────────────────────────────────────


class TestEdgeCases:
    def test_delete_preset(self, manager: PresetManager):
        manager.save(Preset(name="DeleteMe", bank=0, program=1))
        assert manager.load(0, 1).name == "DeleteMe"
        manager.delete(0, 1)
        with pytest.raises(FileNotFoundError):
            manager.load(0, 1)

    def test_get_or_create_bank(self, manager: PresetManager):
        bank = manager.get_or_create_bank(7, "Lead")
        assert bank.number == 7
        assert bank.name == "Lead"
        # Calling again returns existing bank
        bank2 = manager.get_or_create_bank(7)
        assert bank2.number == 7
        assert bank2.name == "Lead"  # Name persisted

    def test_multiple_banks_preset_independence(self, manager: PresetManager):
        """Presets in different banks don't interfere."""
        manager.save(Preset(name="Bank0", bank=0, program=0))
        manager.save(Preset(name="Bank1", bank=1, program=0))
        assert manager.load(0, 0).name == "Bank0"
        assert manager.load(1, 0).name == "Bank1"

    def test_full_bank_capacity(self, manager: PresetManager):
        """All 4 slots in a bank can hold distinct presets."""
        for i in range(4):
            manager.save(Preset(name=f"Slot{i}", bank=2, program=i))
        for i in range(4):
            assert manager.load(2, i).name == f"Slot{i}"

    def test_current_bank_program_tracking(self, manager: PresetManager):
        manager.save(Preset(name="A", bank=0, program=0))
        manager.save(Preset(name="B", bank=0, program=1))
        assert manager.current_bank == 0
        assert manager.current_program == 0
        manager.select(0, 1)
        assert manager.current_program == 1

    def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
        path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
        path.parent.mkdir(parents=True)
        path.write_text("not valid json", encoding="utf-8")
        with pytest.raises(json.JSONDecodeError):
            manager.load(0, 0)


# ── Channel independence ────────────────────────────────────────────────


class TestChannelIndependence:
    """Guitar and bass channels each have their own preset bank namespace."""

    def test_channel_serialization_round_trip(self):
        """Channel field survives to_dict / from_dict."""
        p = Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0)
        d = _preset_to_dict(p)
        assert d["channel"] == "guitar"
        restored = _preset_from_dict(d)
        assert restored.channel == Channel.GUITAR

        p2 = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
        d2 = _preset_to_dict(p2)
        assert d2["channel"] == "bass"
        restored2 = _preset_from_dict(d2)
        assert restored2.channel == Channel.BASS

    def test_channel_defaults_to_guitar(self):
        """Presets without explicit channel default to guitar."""
        p = Preset(name="Default", bank=0, program=0)
        assert p.channel == Channel.GUITAR

    def test_guitar_and_bass_independent_banks(self, manager: PresetManager):
        """Guitar and bass presets in the same (bank, program) don't collide."""
        gtr = Preset(name="GTR Crunch", channel=Channel.GUITAR, bank=0, program=0)
        bass = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)

        # Save guitar via current channel (defaults to guitar)
        manager.save(gtr)
        # Save bass explicitly
        manager.save(bass, channel=Channel.BASS)

        # Load guitar
        loaded_gtr = manager.load(0, 0, Channel.GUITAR)
        assert loaded_gtr.name == "GTR Crunch"
        assert loaded_gtr.channel == Channel.GUITAR

        # Load bass
        loaded_bass = manager.load(0, 0, Channel.BASS)
        assert loaded_bass.name == "Bass Slap"
        assert loaded_bass.channel == Channel.BASS

    def test_channel_switch_restores_preset(self, manager: PresetManager):
        """Switching channels restores the last active state for that channel."""
        manager.save(Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0))
        manager.save(Preset(name="GTR Clean", channel=Channel.GUITAR, bank=0, program=1))
        manager.save(Preset(name="Bass Slap", channel=Channel.BASS, bank=1, program=2))

        # Select guitar preset (bank=0, program=1)
        manager.select(0, 1, Channel.GUITAR)

        # Select bass preset so its channel state is saved
        manager.select(1, 2, Channel.BASS)

        # Switch to guitar — should go to (0, 1)
        manager.set_channel(Channel.GUITAR)
        assert manager.current_channel == Channel.GUITAR
        assert manager.current_bank == 0
        assert manager.current_program == 1

        # Switch back to bass — should restore (1, 2)
        manager.set_channel(Channel.BASS)
        assert manager.current_channel == Channel.BASS
        assert manager.current_bank == 1
        assert manager.current_program == 2

    def test_set_same_channel_noop(self, manager: PresetManager):
        """set_channel to the same channel preserves current position."""
        manager.save(Preset(name="Only", channel=Channel.GUITAR, bank=0, program=2))
        manager.select(0, 2, Channel.GUITAR)
        result = manager.set_channel(Channel.GUITAR)
        assert result.name == "Only"
        assert manager.current_bank == 0
        assert manager.current_program == 2

    def test_current_channel_property(self, manager: PresetManager):
        """current_channel returns the active channel."""
        assert manager.current_channel == Channel.GUITAR
        manager.set_channel(Channel.BASS)
        assert manager.current_channel == Channel.BASS
        manager.set_channel(Channel.GUITAR)
        assert manager.current_channel == Channel.GUITAR

    def test_list_banks_channel_scoped(self, manager: PresetManager):
        """list_banks only returns banks for the specified channel."""
        manager.save(Preset(name="GTR_A", channel=Channel.GUITAR, bank=0, program=0))
        manager.save(Preset(name="BASS_A", channel=Channel.BASS, bank=5, program=0))

        gtr_banks = manager.list_banks(Channel.GUITAR)
        assert len(gtr_banks) == 1
        assert gtr_banks[0].number == 0

        bass_banks = manager.list_banks(Channel.BASS)
        assert len(bass_banks) == 1
        assert bass_banks[0].number == 5

    def test_migrate_legacy_presets(self, tmp_path: Path):
        """Legacy flat bank_* dirs are migrated to guitar/ on first boot."""
        # Create legacy layout: bank_0/preset_0.json at root
        legacy_bank = tmp_path / "bank_0"
        legacy_bank.mkdir()
        (legacy_bank / "preset_0.json").write_text(
            json.dumps({"name": "Legacy Tone", "bank": 0, "program": 0}),
            encoding="utf-8",
        )

        # Create PresetManager — should migrate
        pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)

        # Old location should be gone
        assert not (tmp_path / "bank_0").exists()

        # New location should exist
        guitar_path = tmp_path / "guitar" / "bank_0" / "preset_0.json"
        assert guitar_path.exists()

        # Preset should be loadable from guitar channel
        preset = pm.load(0, 0, Channel.GUITAR)
        assert preset.name == "Legacy Tone"

    def test_migration_skips_when_already_migrated(self, tmp_path: Path):
        """Migration doesn't run if guitar/ dir already exists."""
        guitar_dir = tmp_path / "guitar"
        guitar_dir.mkdir(parents=True)
        (guitar_dir / "bank_0").mkdir()
        (guitar_dir / "bank_0" / "preset_0.json").write_text(
            json.dumps({"name": "Already Migrated", "channel": "guitar", "bank": 0, "program": 0}),
            encoding="utf-8",
        )

        pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
        preset = pm.load(0, 0, Channel.GUITAR)
        assert preset.name == "Already Migrated"

    def test_fresh_install_no_migration(self, tmp_path: Path):
        """Fresh install with no files doesn't raise or create guitar dir."""
        pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
        assert not (tmp_path / "guitar").exists()
        assert pm.list_banks() == []