"""Preset and bank manager — save, load, navigate, and auto-restore presets.

The PresetManager owns the on-disk preset store and provides:
- Human-readable JSON preset storage
- Bank structure with 4 presets per bank
- Footswitch navigation (preset-up/down wraps within bank, bank-up/down switches banks)
- MIDI Program Change routing (bank=channel, program=preset)
- Auto-restore of last active preset on power cycle
- Factory preset installation
- Preset rename and reorder
"""

from __future__ import annotations

import json
import logging
import shutil
import threading
from pathlib import Path
from typing import Optional

from .types import Bank, Channel, FXBlock, FXType, MIDIMapping, Preset, Snapshot

logger = logging.getLogger(__name__)

# ── Constants ───────────────────────────────────────────────────────────────

PRESETS_PER_BANK = 4
"""Number of presets per bank (convention: footswitch 1-4 within a bank)."""

AUTO_SAVE_DELAY_S = 1.0
"""Debounce delay in seconds before writing auto-save state to disk."""

FACTORY_PRESET_DIR = Path(__file__).resolve().parent.parent.parent / "presets" / "factory"
"""Location of bundled factory preset JSON files."""

FACTORY_IR_DIR = FACTORY_PRESET_DIR / "irs"
"""Location of bundled factory cabinet IR .wav files."""


# ── Serialisation helpers ───────────────────────────────────────────────────

def _preset_to_dict(preset: Preset) -> dict:
    """Serialize a Preset to a JSON-compatible dict."""
    return {
        "name": preset.name,
        "channel": preset.channel.value,
        "bank": preset.bank,
        "program": preset.program,
        "master_volume": preset.master_volume,
        "routing_mode": preset.routing_mode,
        "routing_breakpoint": preset.routing_breakpoint,
        "tuner_enabled": preset.tuner_enabled,
        "chain": [
            {
                "fx_type": block.fx_type.value,
                "enabled": block.enabled,
                "bypass": block.bypass,
                "params": dict(block.params),
                "nam_model_path": block.nam_model_path,
                "ir_file_path": block.ir_file_path,
            }
            for block in preset.chain
        ],
        "midi_mappings": {
            key: {
                "cc_number": mapping.cc_number,
                "channel": mapping.channel,
                "min_val": mapping.min_val,
                "max_val": mapping.max_val,
            }
            for key, mapping in preset.midi_mappings.items()
        },
        "snapshots": {
            str(slot): {
                "name": snap.name,
                "master_volume": snap.master_volume,
                "chain": [
                    {
                        "fx_type": block.fx_type.value,
                        "enabled": block.enabled,
                        "bypass": block.bypass,
                        "params": dict(block.params),
                        "nam_model_path": block.nam_model_path,
                        "ir_file_path": block.ir_file_path,
                    }
                    for block in snap.chain
                ],
            }
            for slot, snap in preset.snapshots.items()
        },
    }


def _preset_from_dict(data: dict) -> Preset:
    """Deserialize a Preset from a JSON-compatible dict."""
    chain = []
    for block_data in data.get("chain", []):
        chain.append(
            FXBlock(
                fx_type=FXType(block_data["fx_type"]),
                enabled=block_data.get("enabled", True),
                bypass=block_data.get("bypass", False),
                params=dict(block_data.get("params", {})),
                nam_model_path=block_data.get("nam_model_path", ""),
                ir_file_path=block_data.get("ir_file_path", ""),
            )
        )

    midi_mappings = {}
    for key, md in data.get("midi_mappings", {}).items():
        midi_mappings[key] = MIDIMapping(
            cc_number=md.get("cc_number", 0),
            channel=md.get("channel", 0),
            min_val=md.get("min_val", 0.0),
            max_val=md.get("max_val", 1.0),
        )

    snapshots = {}
    for slot_str, snap_data in data.get("snapshots", {}).items():
        snap_chain = []
        for bd in snap_data.get("chain", []):
            snap_chain.append(
                FXBlock(
                    fx_type=FXType(bd["fx_type"]),
                    enabled=bd.get("enabled", True),
                    bypass=bd.get("bypass", False),
                    params=dict(bd.get("params", {})),
                    nam_model_path=bd.get("nam_model_path", ""),
                    ir_file_path=bd.get("ir_file_path", ""),
                )
            )
        snapshots[int(slot_str)] = Snapshot(
            name=snap_data.get("name", f"Snapshot {slot_str}"),
            chain=snap_chain,
            master_volume=snap_data.get("master_volume", 0.8),
        )

    return Preset(
        name=data["name"],
        channel=Channel(data.get("channel", "guitar")),
        bank=data.get("bank", 0),
        program=data.get("program", 0),
        chain=chain,
        midi_mappings=midi_mappings,
        master_volume=data.get("master_volume", 0.8),
        routing_mode=data.get("routing_mode", "mono"),
        routing_breakpoint=data.get("routing_breakpoint", 7),
        tuner_enabled=data.get("tuner_enabled", False),
        snapshots=snapshots,
    )


# ── The PresetManager ───────────────────────────────────────────────────────


class PresetManager:
    """Manages preset/bank storage, navigation, MIDI binding, and auto-save.

    Args:
        preset_dir: Directory for the preset store (created if missing).
        audio_pipeline: Optional AudioPipeline to activate presets on.
            When set, ``activate()`` calls ``pipeline.load_preset()``.
    """

    def __init__(
        self,
        preset_dir: str | Path = "~/.pedal/presets",
        audio_pipeline: object = None,
    ) -> None:
        self._dir = Path(preset_dir).expanduser().resolve()
        self._dir.mkdir(parents=True, exist_ok=True)
        self._pipeline = audio_pipeline

        # Runtime state
        self._current_channel: Channel = Channel.GUITAR
        self._current_bank: int = 0
        self._current_program: int = 0
        self._dirty = False
        self._lock = threading.Lock()

        # Register of known bank numbers (populated lazily)
        self._known_banks: set[int] = set()

        # Auto-save debounce timer
        self._auto_save_timer: Optional[threading.Timer] = None

        # Current snapshot slot (0 = none selected)
        self._current_snapshot: int = 0

        # Migrate legacy flat presets → gtr channel on first boot
        self._migrate_legacy_presets()

        logger.info("PresetManager root: %s (channel=%s)", self._dir, self._current_channel.value)

    # ── Public navigation API ───────────────────────────────────────────────

    @property
    def current_channel(self) -> Channel:
        return self._current_channel

    @property
    def current_bank(self) -> int:
        return self._current_bank

    @property
    def current_program(self) -> int:
        return self._current_program

    def set_channel(self, channel: Channel) -> Preset:
        """Switch the active channel and restore its last preset.

        Args:
            channel: The channel to switch to.

        Returns:
            The currently active Preset in the new channel.
        """
        if channel == self._current_channel:
            try:
                return self.load(self._current_bank, self._current_program)
            except FileNotFoundError:
                pass
            return self._select_and_activate(self._current_bank, self._current_program)

        # Save current channel state first
        self.save_state()

        self._current_channel = channel

        # Try restoring channel-specific state
        restored = self._restore_channel_state()
        if restored:
            return restored

        # Default to bank 0, program 0 in the new channel
        try:
            return self._select_and_activate(0, 0)
        except Exception:
            # Channel is empty — create first blank preset
            return self._select_and_activate(0, 0)

    @property
    def current_preset_path(self) -> Path:
        """Path to the slot for the current (channel, bank, program)."""
        return self._preset_path(self._current_channel, self._current_bank, self._current_program)

    def preset_up(self) -> Preset:
        """Select next preset in current bank (wraps 3→0).

        Returns:
            The activated Preset.
        """
        bank = self._get_or_create_bank(self._current_bank)
        prog = (self._current_program + 1) % PRESETS_PER_BANK
        return self._select_and_activate(self._current_bank, prog)

    def preset_down(self) -> Preset:
        """Select previous preset in current bank (wraps 0→3).

        Returns:
            The activated Preset.
        """
        prog = (self._current_program - 1) % PRESETS_PER_BANK
        return self._select_and_activate(self._current_bank, prog)

    def bank_up(self) -> tuple[Bank, Preset]:
        """Move to the next higher-numbered bank.

        If there are no banks above the current one, wraps to the
        lowest known bank number.

        Returns:
            (Bank, Preset) — the new bank and its currently active preset.
        """
        return self._switch_bank(+1)

    def bank_down(self) -> tuple[Bank, Preset]:
        """Move to the next lower-numbered bank (wraps around).

        Returns:
            (Bank, Preset) — the new bank and its currently active preset.
        """
        return self._switch_bank(-1)

    def select(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
        """Directly select a specific (bank, program) slot.

        Args:
            bank: Bank number.
            program: Preset index (0-3) within the bank.
            channel: Optional channel override (defaults to current).

        Returns:
            The activated Preset.
        """
        return self._select_and_activate(bank, program, channel or self._current_channel)

    def midi_pc(self, channel: int, program: int) -> Preset:
        """Handle MIDI Program Change: bank=channel, program=program.

        Args:
            channel: MIDI channel (used as bank number).
            program: MIDI program number (used as preset index).

        Returns:
            The activated Preset.
        """
        logger.info("MIDI PC: bank=%d, program=%d", channel, program)
        return self._select_and_activate(channel, program)

    # ── CRUD ────────────────────────────────────────────────────────────────

    def save(self, preset: Preset, *, auto: bool = False, channel: Channel | None = None) -> None:
        """Save a preset to its (bank, program) slot on disk.

        Args:
            preset: The preset to persist.
            auto: If True, this was triggered by auto-save and the
                dirty/current-state write is implicitly handled.
            channel: Channel to save under (defaults to preset.channel).
        """
        ch = channel or preset.channel
        path = self._preset_path(ch, preset.bank, preset.program)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            json.dumps(_preset_to_dict(preset), indent=2, sort_keys=True),
            encoding="utf-8",
        )

        if not auto:
            self._dirty = True
            self._known_banks.add(preset.bank)

        # Save/update bank metadata
        self._save_bank_meta(ch, preset.bank)

        logger.info("Saved preset '%s' → %s", preset.name, path)

    def load(self, bank: int, program: int, channel: Channel | None = None) -> Preset:
        """Load a preset from disk.

        Args:
            bank: Bank number.
            program: Preset index (0-3).
            channel: Channel to load from (defaults to current).

        Returns:
            The deserialized Preset.

        Raises:
            FileNotFoundError: If the preset slot doesn't exist.
            json.JSONDecodeError: If the file is corrupt.
        """
        ch = channel or self._current_channel
        path = self._preset_path(ch, bank, program)
        if not path.exists():
            raise FileNotFoundError(
                f"No preset at (channel={ch.value}, bank={bank}, program={program}): {path}"
            )
        data = json.loads(path.read_text(encoding="utf-8"))
        preset = _preset_from_dict(data)
        # Ensure metadata is correct even if file was manually altered
        preset.channel = ch
        preset.bank = bank
        preset.program = program
        return preset

    def delete(self, bank: int, program: int, channel: Channel | None = None) -> None:
        """Delete a preset slot from disk.

        Args:
            bank: Bank number.
            program: Preset index.
            channel: Channel to delete from (defaults to current).
        """
        ch = channel or self._current_channel
        path = self._preset_path(ch, bank, program)
        if path.exists():
            path.unlink()
            logger.info("Deleted preset at (channel=%s, bank=%d, program=%d)", ch.value, bank, program)
        self._dirty = True

    def rename(self, bank: int, program: int, new_name: str, channel: Channel | None = None) -> Preset:
        """Rename a preset and re-save it.

        Args:
            bank: Bank number.
            program: Preset index.
            new_name: Replacement name.
            channel: Channel (defaults to current).

        Returns:
            The renamed Preset.
        """
        preset = self.load(bank, program, channel)
        preset.name = new_name
        self.save(preset, channel=channel)
        logger.info("Renamed preset at (%s,%d,%d) → '%s'",
                     (channel or self._current_channel).value, bank, program, new_name)
        return preset

    def reorder(self, bank: int, program: int, new_program: int) -> None:
        """Move a preset to a different slot within the same bank.

        If the target slot is occupied the two presets are swapped.
        If the current active preset is being moved, tracking is updated.

        Args:
            bank: Bank number.
            program: Current program index.
            new_program: Target program index (0-3).
        """
        if program == new_program:
            return
        if not (0 <= new_program < PRESETS_PER_BANK):
            raise ValueError(f"Program index {new_program} out of range [0, {PRESETS_PER_BANK})")

        # Load both existing presets (or create empty placeholder for empty slot)
        try:
            src = self.load(bank, program)
        except FileNotFoundError:
            raise FileNotFoundError(f"Cannot reorder: no preset at (bank={bank}, program={program})")

        try:
            dst = self.load(bank, new_program)
            src.program = new_program
            dst.program = program
            self.save(dst)
        except FileNotFoundError:
            src.program = new_program

        self.save(src)

        # Update current tracking if we moved the active preset
        with self._lock:
            if self._current_bank == bank and self._current_program == program:
                self._current_program = new_program

        logger.info("Reordered preset at (%d,%d) → (%d,%d)", bank, program, bank, new_program)

    # ── 4CM routing config ──────────────────────────────────────────────

    def get_4cm_config(self, preset: Preset) -> tuple[str, int]:
        """Return (routing_mode, routing_breakpoint) from a preset.

        Returns:
            Tuple of ``(routing_mode, routing_breakpoint)``.
        """
        return (preset.routing_mode, preset.routing_breakpoint)

    def set_4cm_config(
        self, preset: Preset, mode: str, breakpoint: int = 7
    ) -> None:
        """Set 4CM routing configuration on a preset in-place and save it.

        Args:
            preset: The preset to modify.
            mode: ``\"mono\"`` or ``\"4cm\"``.
            breakpoint: Chain index where the pre/post split occurs
                (default 7 = after IR_CAB, before mod FX).

        Raises:
            ValueError: If ``mode`` is not ``\"mono\"`` or ``\"4cm\"``.
        """
        if mode not in ("mono", "4cm"):
            raise ValueError(f"routing_mode must be 'mono' or '4cm', got {mode!r}")
        preset.routing_mode = mode
        preset.routing_breakpoint = breakpoint
        self.save(preset)
        logger.info("Set 4CM config on '%s': mode=%s breakpoint=%d",
                     preset.name, mode, breakpoint)

    # ── Bank management ─────────────────────────────────────────────────────

    def list_banks(self, channel: Channel | None = None) -> list[Bank]:
        """Scan the preset directory and return known banks sorted by number.

        Args:
            channel: Channel to list banks for (defaults to current).

        Returns:
            List of Bank objects sorted by number.
        """
        ch = channel or self._current_channel
        ch_dir = self._dir / ch.value
        if not ch_dir.is_dir():
            return []

        banks: dict[int, Bank] = {}
        for meta_path in sorted(ch_dir.glob("bank_*/bank.json")):
            try:
                data = json.loads(meta_path.read_text(encoding="utf-8"))
                num = data.get("number", 0)
                preset_count = sum(
                    1 for _ in meta_path.parent.glob("preset_*.json")
                )
                banks[num] = Bank(
                    name=data.get("name", f"Bank {num}"),
                    number=num,
                    presets=[None] * preset_count,  # Placeholder
                )
            except (json.JSONDecodeError, KeyError):
                continue

        # Also discover banks by scanning dirs without bank.json
        for p_dir in sorted(ch_dir.glob("bank_*")):
            try:
                num = int(p_dir.name.split("_")[1])
                if num not in banks:
                    banks[num] = Bank(name=f"Bank {num}", number=num)
            except (ValueError, IndexError):
                continue

        self._known_banks = set(banks.keys())
        return [banks[k] for k in sorted(banks)]

    def get_or_create_bank(self, number: int, name: str = "") -> Bank:
        """Return an existing bank or create a new one.

        Args:
            number: Bank number.
            name: Optional display name (defaults to f"Bank {number}").

        Returns:
            The Bank object.
        """
        return self._get_or_create_bank(number, name)

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

    def save_state(self) -> None:
        """Persist the current (channel, bank, program) for power-cycle restore.

        Call this whenever the active preset or channel changes to guarantee
        the pedal comes back to the same sound after a power cycle.
        """
        state = {
            "current_channel": self._current_channel.value,
            "current_bank": self._current_bank,
            "current_program": self._current_program,
        }
        state_path = self._dir / "state.json"
        state_path.write_text(
            json.dumps(state, indent=2), encoding="utf-8"
        )
        logger.debug("State saved: channel=%s bank=%d program=%d",
                      self._current_channel.value, self._current_bank, self._current_program)

    def restore_state(self) -> Optional[Preset]:
        """Restore the last active (channel, bank, program) from state.json.

        Returns:
            The restored Preset, or None if no state file exists or the
            saved slot is empty.
        """
        state_path = self._dir / "state.json"
        if not state_path.exists():
            logger.info("No saved state to restore")
            return None

        try:
            state = json.loads(state_path.read_text(encoding="utf-8"))
            ch = Channel(state.get("current_channel", "guitar"))
            bank = state["current_bank"]
            program = state["current_program"]
            preset = self.load(bank, program, ch)
        except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError):
            logger.warning("Could not restore state, falling back to first preset")
            return None

        self._current_channel = ch
        self._current_bank = bank
        self._current_program = program
        self._dirty = False
        logger.info("Restored state: channel=%s bank=%d program=%d '%s'",
                     ch.value, bank, program, preset.name)
        return preset

    # ── Activation ──────────────────────────────────────────────────────────

    def activate(self, preset: Preset) -> None:
        """Apply a preset to the audio pipeline (if one is connected).

        Args:
            preset: The preset to activate.
        """
        if self._pipeline is not None:
            self._pipeline.load_preset(preset)
        logger.info("Activated preset '%s' (bank=%d, program=%d)",
                     preset.name, preset.bank, preset.program)

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

    def install_factory_presets(self, overwrite: bool = False,
                                channel: Channel = Channel.GUITAR) -> int:
        """Install bundled factory presets into the preset store.

        Scans the channel-specific subdirectory of ``FACTORY_PRESET_DIR``
        for ``bank_*/preset_*.json`` files and copies them into the user
        preset store.  For GUITAR the root of ``FACTORY_PRESET_DIR`` is
        scanned (backward-compatible with legacy ``bank_0``–``bank_16``).
        All other channels use ``FACTORY_PRESET_DIR / channel.value``.

        Args:
            overwrite: If True, overwrite existing presets at the same
                (bank, program) slots. If False, skip occupied slots.
            channel: Which channel to install factory presets into (default GUITAR).

        Returns:
            Number of factory presets installed.
        """
        if not FACTORY_PRESET_DIR.is_dir():
            logger.warning("Factory preset directory not found: %s", FACTORY_PRESET_DIR)
            return 0

        # Determine source directory — root for GUITAR, subdir for all others
        source_dir = FACTORY_PRESET_DIR if channel == Channel.GUITAR \
            else FACTORY_PRESET_DIR / channel.value

        if not source_dir.is_dir():
            logger.warning("No factory presets for channel %s: %s",
                           channel.value, source_dir)
            return 0

        count = 0
        for src in sorted(source_dir.glob("bank_*/preset_*.json")):
            # Derive (bank, program) from file path
            bank_dir = src.parent
            try:
                bank_num = int(bank_dir.name.split("_")[1])
                prog_num = int(src.stem.split("_")[1])
            except (ValueError, IndexError):
                logger.warning("Skipping malformed factory preset path: %s", src)
                continue

            dest = self._preset_path(channel, bank_num, prog_num)
            if dest.exists() and not overwrite:
                continue

            dest.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dest)
            self._known_banks.add(bank_num)
            self._save_bank_meta(channel, bank_num)
            count += 1
            logger.debug("Installed factory preset: %s", dest)

        if count:
            self._dirty = True
            logger.info("Installed %d factory presets for %s", count, channel.value)
        return count

    def install_factory_irs(self, overwrite: bool = False) -> int:
        """Install bundled factory cabinet IR .wav files into the IR directory.

        Copies IR files from ``FACTORY_IR_DIR`` into the IR loader's default
        directory (``~/.pedal/irs/``). Existing files are skipped unless
        ``overwrite=True``.

        Args:
            overwrite: If True, overwrite existing IR files with the same name.

        Returns:
            Number of factory IR files installed.
        """
        if not FACTORY_IR_DIR.is_dir():
            logger.warning("Factory IR directory not found: %s", FACTORY_IR_DIR)
            return 0

        from ..dsp.ir_loader import DEFAULT_IR_DIR
        dest = DEFAULT_IR_DIR
        dest.mkdir(parents=True, exist_ok=True)

        count = 0
        for src in sorted(FACTORY_IR_DIR.glob("*.wav")):
            dst = dest / src.name
            if dst.exists() and not overwrite:
                logger.debug("Skipping existing IR: %s", dst.name)
                continue
            shutil.copy2(src, dst)
            count += 1
            logger.debug("Installed factory IR: %s", dst.name)

        if count:
            logger.info("Installed %d factory cab IRs into %s", count, dest)
        return count

    # ── Internal helpers ────────────────────────────────────────────────────

    def _preset_path(self, channel: Channel, bank: int, program: int) -> Path:
        """Compute the on-disk path for a (channel, bank, program) slot."""
        return self._dir / channel.value / f"bank_{bank}" / f"preset_{program}.json"

    def _channel_dir(self, channel: Channel) -> Path:
        """Return the root directory for a given channel."""
        return self._dir / channel.value

    def _bank_dir(self, channel: Channel, bank: int) -> Path:
        return self._channel_dir(channel) / f"bank_{bank}"

    def _bank_meta_path(self, channel: Channel, bank: int) -> Path:
        return self._bank_dir(channel, bank) / "bank.json"

    def _get_or_create_bank(self, number: int, name: str = "",
                            channel: Channel | None = None) -> Bank:
        """Return an existing bank or create a new one on disk.

        Args:
            number: Bank number.
            name: Optional display name.
            channel: Channel (defaults to current).
        """
        ch = channel or self._current_channel
        meta_path = self._bank_meta_path(ch, number)
        if meta_path.exists():
            try:
                data = json.loads(meta_path.read_text(encoding="utf-8"))
                return Bank(
                    name=data.get("name", name or f"Bank {number}"),
                    number=data.get("number", number),
                )
            except (json.JSONDecodeError, KeyError):
                pass

        # Create new bank
        bank = Bank(name=name or f"Bank {number}", number=number)
        self._save_bank_meta(ch, number, bank.name)
        self._known_banks.add(number)
        logger.info("Created bank %d: '%s' (channel=%s)", number, bank.name, ch.value)
        return bank

    def _save_bank_meta(self, channel: Channel, bank_num: int,
                        name: str | None = None) -> None:
        """Write or update a bank's metadata file."""
        meta_path = self._bank_meta_path(channel, bank_num)
        existing = {}
        if meta_path.exists():
            try:
                existing = json.loads(meta_path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                pass

        meta = {
            "number": bank_num,
            "name": name or existing.get("name", f"Bank {bank_num}"),
            "preset_count": PRESETS_PER_BANK,
        }
        meta_path.parent.mkdir(parents=True, exist_ok=True)
        meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")

    def _switch_bank(self, direction: int) -> tuple[Bank, Preset]:
        """Move +1 or -1 through known banks, wrapping at edges.

        Args:
            direction: +1 for bank_up, -1 for bank_down.

        Returns:
            (Bank, Preset) of the new active slot.
        """
        banks = self.list_banks()
        if not banks:
            raise RuntimeError("No banks available")

        current_idx = next(
            (i for i, b in enumerate(banks) if b.number == self._current_bank),
            None,
        )
        if current_idx is None:
            current_idx = 0

        new_idx = (current_idx + direction) % len(banks)
        new_bank = banks[new_idx]

        # Activate preset at same program index within the new bank,
        # defaulting to a blank preset if the slot is empty
        try:
            preset = self.load(new_bank.number, self._current_program)
        except FileNotFoundError:
            preset = Preset(
                name=f"Empty {new_bank.name}",
                channel=self._current_channel,
                bank=new_bank.number,
                program=self._current_program,
            )

        self._current_bank = new_bank.number
        self._current_program = preset.program
        self.activate(preset)
        self._save_channel_state()
        self.save_state()
        logger.info("Switched to bank %d, preset '%s' (channel=%s)",
                     new_bank.number, preset.name, self._current_channel.value)
        return new_bank, preset

    def _select_and_activate(self, bank: int, program: int,
                             channel: Channel | None = None) -> Preset:
        """Select a (bank, program) slot and activate it.

        If the slot is empty, creates a blank preset in that slot first.

        Args:
            bank: Bank number.
            program: Preset index (0-3).
            channel: Channel to operate on (defaults to current).

        Returns:
            The activated Preset.
        """
        ch = channel or self._current_channel
        program = max(0, min(PRESETS_PER_BANK - 1, program))

        try:
            preset = self.load(bank, program, ch)
        except FileNotFoundError:
            # Create a blank placeholder preset
            bank_obj = self._get_or_create_bank(bank, channel=ch)
            preset = Preset(
                name=f"New {bank_obj.name} #{program}",
                channel=ch, bank=bank, program=program,
            )
            self.save(preset, auto=True, channel=ch)

        self._current_channel = ch
        self._current_bank = bank
        self._current_program = program
        self.activate(preset)
        self._save_channel_state(ch)
        self.save_state()
        return preset

    # ── Channel state helpers ───────────────────────────────────────────

    def _channel_state_path(self, channel: Channel) -> Path:
        """Path to the per-channel state file."""
        return self._channel_dir(channel) / "channel_state.json"

    def _save_channel_state(self, channel: Channel | None = None) -> None:
        """Persist the channel-specific (bank, program) state."""
        ch = channel or self._current_channel
        state = {
            "current_bank": self._current_bank,
            "current_program": self._current_program,
        }
        path = self._channel_state_path(ch)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(state, indent=2), encoding="utf-8")
        logger.debug("Channel state saved for %s: bank=%d program=%d",
                      ch.value, self._current_bank, self._current_program)

    def _restore_channel_state(self, channel: Channel | None = None) -> Optional[Preset]:
        """Restore the per-channel (bank, program) state.

        Args:
            channel: Channel to restore (defaults to current).

        Returns:
            The restored Preset, or None if no saved state exists.
        """
        ch = channel or self._current_channel
        path = self._channel_state_path(ch)
        if not path.exists():
            return None

        try:
            state = json.loads(path.read_text(encoding="utf-8"))
            bank = state["current_bank"]
            program = state["current_program"]
            preset = self.load(bank, program, ch)
        except (FileNotFoundError, json.JSONDecodeError, KeyError):
            return None

        self._current_bank = bank
        self._current_program = program
        logger.info("Restored channel state for %s: bank=%d program=%d '%s'",
                     ch.value, bank, program, preset.name)
        return preset

    # ── Legacy migration ────────────────────────────────────────────────

    def _migrate_legacy_presets(self) -> None:
        """Migrate flat bank_* directories under root into gtr/ subdirectory.

        Before dual-channel support, presets were stored as:
            {root}/bank_0/preset_0.json

        Now they should live under:
            {root}/gtr/bank_0/preset_0.json

        This runs once on first boot with the new layout.
        """
        ch_dir = self._dir / "guitar"
        if ch_dir.exists():
            # Already migrated or new install — nothing to do
            return

        # Check if there are legacy flat bank_* dirs
        legacy_banks = sorted(self._dir.glob("bank_*"))
        if not legacy_banks:
            return  # Fresh install, nothing to migrate

        logger.info("Migrating %d legacy bank directories → gtr channel ...", len(legacy_banks))
        ch_dir.mkdir(parents=True, exist_ok=True)

        for src in legacy_banks:
            dest = ch_dir / src.name
            try:
                shutil.move(str(src), str(dest))
                logger.debug("  migrated %s → %s", src.name, dest)
            except OSError as e:
                logger.warning("  failed to migrate %s: %s", src.name, e)

        # Also migrate state.json if it exists at root
        legacy_state = self._dir / "state.json"
        if legacy_state.exists():
            try:
                state = json.loads(legacy_state.read_text(encoding="utf-8"))
                if "current_channel" not in state:
                    state["current_channel"] = "guitar"
                    legacy_state.write_text(
                        json.dumps(state, indent=2), encoding="utf-8"
                    )
            except (json.JSONDecodeError, OSError):
                pass

        logger.info("Legacy preset migration complete")