"""MIDI handler — hardware I/O, parsing, routing, MIDI Learn, and clock sync.

Supports:
- 5-pin DIN via UART (pyserial, 31.25 kbaud)
- USB-MIDI class-compliant (python-rtmidi)
- Program Change (PC) → preset switching callback
- Control Change (CC) → parameter control, expression pedal, MIDI Learn
- MIDI clock → BPM tracking for delay/reverb sync
- Running status byte handling
- 14-bit CC (MSB/LSB) for expression pedal resolution
"""

from __future__ import annotations

import logging
import queue
import struct
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Callable, Optional

from src.presets.types import MIDIMapping

logger = logging.getLogger(__name__)

# MIDI protocol constants
MIDI_BAUD = 31250
CLOCK_PPQN = 24  # Pulses Per Quarter Note
MIDI_CLOCK_INTERVAL_BPM_120 = 0.020833  # ~20.8 ms at 120 BPM

# Status byte masks
STATUS_MASK = 0x80
CHANNEL_MASK = 0x0F
STATUS_TYPE_MASK = 0xF0

# MIDI status bytes
NOTE_OFF = 0x80
NOTE_ON = 0x90
POLY_PRESSURE = 0xA0
CONTROL_CHANGE = 0xB0
PROGRAM_CHANGE = 0xC0
CHANNEL_PRESSURE = 0xD0
PITCH_BEND = 0xE0
SYSTEM = 0xF0  # System exclusive / common / real-time

# Real-time messages (single byte, no data)
RT_CLOCK = 0xF8
RT_TICK = 0xF9
RT_START = 0xFA
RT_CONTINUE = 0xFB
RT_STOP = 0xFC
RT_ACTIVE_SENSING = 0xFE
RT_RESET = 0xFF

# System Common
SYS_EXCLUSIVE = 0xF0
SYS_EXCLUSIVE_END = 0xF7
SONG_POSITION = 0xF2
SONG_SELECT = 0xF3
TUNE_REQUEST = 0xF6

# Well-known CC numbers
CC_BANK_SELECT_MSB = 0
CC_MODULATION = 1
CC_BREATH = 2
CC_FOOT_CONTROLLER = 4
CC_VOLUME = 7
CC_PAN = 10
CC_EXPRESSION = 11
CC_BANK_SELECT_LSB = 32
CC_SUSTAIN = 64
CC_ALL_SOUNDS_OFF = 120
CC_RESET_ALL_CONTROLLERS = 121
CC_ALL_NOTES_OFF = 123

# Number of voice messages per data byte count
_MESSAGE_LENGTH: dict[int, int] = {
    NOTE_OFF: 3,
    NOTE_ON: 3,
    POLY_PRESSURE: 3,
    CONTROL_CHANGE: 3,
    PROGRAM_CHANGE: 2,
    CHANNEL_PRESSURE: 2,
    PITCH_BEND: 3,
}


@dataclass
class MIDIEvent:
    """A parsed MIDI message."""

    type: str  # "note_on", "note_off", "cc", "pc", "clock", "start", "stop",
    # "continue", "pitch_bend", "poly_pressure", "channel_pressure",
    # "sys_exclusive", "song_position", "song_select"
    channel: int = 0
    note: int = 0
    velocity: int = 0
    cc_number: int = 0
    cc_value: int = 0
    program: int = 0
    data: bytes = b""


@dataclass
class LearnedMapping:
    """Result of a MIDI Learn operation."""
    cc_number: int
    channel: int
    param_key: str
    min_val: float = 0.0
    max_val: float = 1.0


class MIDIInterface:
    """Abstract base for a MIDI I/O port."""

    def open(self) -> bool:
        ...

    def close(self) -> None:
        ...

    def read(self, timeout: float = 0) -> list[bytes]:
        ...

    def send(self, msg: bytes) -> None:
        ...

    @property
    def is_open(self) -> bool:
        return False

    @property
    def name(self) -> str:
        return ""


class UARTMIDI(MIDIInterface):
    """5-pin DIN MIDI via UART (pyserial at 31.25 kbaud)."""

    def __init__(self, port: str = "/dev/ttyAMA0"):
        self._port_name = port
        self._serial: Any = None  # serial.Serial

    @property
    def name(self) -> str:
        return f"UART:{self._port_name}"

    @property
    def is_open(self) -> bool:
        return self._serial is not None and self._serial.is_open

    def open(self) -> bool:
        try:
            import serial
            self._serial = serial.Serial(
                port=self._port_name,
                baudrate=MIDI_BAUD,
                bytesize=serial.EIGHTBITS,
                parity=serial.PARITY_NONE,
                stopbits=serial.STOPBITS_ONE,
                timeout=0.001,  # 1 ms read timeout
            )
            logger.info("UART MIDI opened on %s", self._port_name)
            return True
        except (ImportError, OSError) as e:
            logger.error("UART MIDI open failed: %s", e)
            return False

    def close(self) -> None:
        if self._serial and self._serial.is_open:
            try:
                self._serial.close()
                logger.info("UART MIDI closed")
            except Exception as e:
                logger.warning("UART MIDI close error: %s", e)

    def read(self, timeout: float = 0) -> list[bytes]:
        """Read available MIDI bytes and return complete messages."""
        if not self.is_open:
            return []
        try:
            raw = self._serial.read(256)
            if not raw:
                return []
            return self._parse_raw_messages(raw)
        except Exception as e:
            logger.warning("UART read error: %s", e)
            return []

    def send(self, msg: bytes) -> None:
        if not self.is_open:
            return
        try:
            self._serial.write(msg)
        except Exception as e:
            logger.warning("UART write error: %s", e)

    @staticmethod
    def _parse_raw_messages(raw: bytes) -> list[bytes]:
        """Split raw byte stream into complete MIDI messages.

        Handles running status: status byte is re-used for subsequent
        data-only bytes until a new status byte arrives.
        """
        messages: list[bytes] = []
        i = 0
        running_status: int | None = None

        while i < len(raw):
            byte = raw[i]

            if byte & STATUS_MASK:  # Status byte
                if byte >= 0xF8:  # Real-time (single byte)
                    messages.append(bytes([byte]))
                    i += 1
                    continue

                if byte == SYS_EXCLUSIVE:  # SysEx start
                    # Find end marker
                    end = raw.find(SYS_EXCLUSIVE_END, i + 1)
                    if end == -1:
                        break  # Incomplete SysEx, wait for more
                    messages.append(raw[i: end + 1])
                    i = end + 1
                    running_status = None
                    continue

                # Voice or system common status
                if byte in _MESSAGE_LENGTH:
                    length = _MESSAGE_LENGTH[byte]
                    running_status = byte
                elif SONG_POSITION <= byte <= SONG_SELECT:
                    length = {SONG_POSITION: 3, SONG_SELECT: 2}.get(byte, 1)
                    running_status = None  # Don't run on system common
                elif byte == TUNE_REQUEST:
                    messages.append(bytes([byte]))
                    i += 1
                    continue
                else:
                    # Unknown status, skip
                    i += 1
                    continue

                if i + length > len(raw):
                    break  # Incomplete, wait for more data
                messages.append(raw[i: i + length])
                i += length
                continue

            # Data byte with running status
            if running_status is not None and running_status in _MESSAGE_LENGTH:
                length = _MESSAGE_LENGTH[running_status]
                # We need length-1 more bytes after this one
                if i + length - 1 > len(raw):
                    break
                msg = bytes([running_status]) + raw[i: i + length - 1]
                messages.append(msg)
                i += length - 1
            else:
                # Stray data byte, skip
                i += 1

        return messages


class USBMIDI(MIDIInterface):
    """USB-MIDI class-compliant port (python-rtmidi)."""

    def __init__(self, port_name: str = ""):
        self._port_name = port_name
        self._midi_in: Any = None  # rtmidi.MidiIn
        self._midi_out: Any = None  # rtmidi.MidiOut
        self._queue: queue.Queue[bytes] = queue.Queue()
        self._callback: Any = None

    @property
    def name(self) -> str:
        return f"USB:{self._port_name or 'auto'}"

    @property
    def is_open(self) -> bool:
        return self._midi_in is not None

    def open(self) -> bool:
        try:
            import rtmidi
            self._midi_in = rtmidi.MidiIn()
            self._midi_out = rtmidi.MidiOut()
            self._midi_in.ignore_types(timing=True, sysex=False)

            # Find matching port
            ports_in = self._midi_in.get_ports()
            ports_out = self._midi_out.get_ports()

            target_in = None
            target_out = None

            for i, p in enumerate(ports_in):
                if not self._port_name or self._port_name.lower() in p.lower():
                    target_in = i
                    break

            for i, p in enumerate(ports_out):
                if not self._port_name or self._port_name.lower() in p.lower():
                    target_out = i
                    break

            if target_in is None:
                logger.warning("No USB-MIDI input port found (available: %s)", ports_in)
                self._midi_in = None
                return False

            if target_out is None:
                logger.warning("No USB-MIDI output port found, output disabled")
                target_out = None

            self._midi_in.open_port(target_in)
            logger.info("USB-MIDI in opened on port %d: %s", target_in, ports_in[target_in])

            if target_out is not None:
                self._midi_out.open_port(target_out)
                logger.info("USB-MIDI out opened on port %d: %s", target_out, ports_out[target_out])

            self._midi_in.set_callback(self._on_midi)
            return True

        except ImportError:
            logger.warning("python-rtmidi not available — USB-MIDI disabled")
            return False
        except Exception as e:
            logger.warning("USB-MIDI open failed: %s", e)
            return False

    def _on_midi(self, event: tuple[list[int], float], data: Any = None) -> None:
        """Callback from rtmidi on incoming message."""
        msg, _timestamp = event
        self._queue.put(bytes(msg))

    def close(self) -> None:
        if self._midi_in:
            try:
                self._midi_in.close_port()
            except Exception:
                pass
        if self._midi_out:
            try:
                self._midi_out.close_port()
            except Exception:
                pass
        self._midi_in = None
        self._midi_out = None
        logger.info("USB-MIDI closed")

    def read(self, timeout: float = 0) -> list[bytes]:
        """Drain the incoming queue."""
        msgs: list[bytes] = []
        try:
            while True:
                msgs.append(self._queue.get_nowait())
        except queue.Empty:
            pass
        return msgs

    def send(self, msg: bytes) -> None:
        if not self._midi_out:
            return
        try:
            self._midi_out.send_message(list(msg))
        except Exception as e:
            logger.warning("USB-MIDI write error: %s", e)


class MIDIHandler:
    """Core MIDI handler — manages I/O ports, message parsing, and routing.

    Usage::

        handler = MIDIHandler()
        handler.set_pc_callback(lambda ch, pg: print(f"Preset {pg}"))
        handler.register_cc(11, lambda val, ch: print(f"Expression: {val}"))
        handler.set_clock_callback(lambda bpm: print(f"Tempo: {bpm:.1f}"))
        handler.start(uart_port="/dev/ttyAMA0", usb=True)
        ...
        handler.stop()
    """

    def __init__(self) -> None:
        # Callbacks
        self._pc_callback: Optional[Callable[[int, int], None]] = None
        self._cc_callbacks: dict[int, Callable[[int, int], None]] = {}
        self._midi_learn_callback: Optional[Callable[[LearnedMapping], None]] = None
        self._clock_callback: Optional[Callable[[float], None]] = None
        self._note_callback: Optional[Callable[[int, int, int], None]] = None

        # MIDI Learn state
        self._learn_mode = False
        self._learn_target: Optional[str] = None
        self._mappings: dict[str, MIDIMapping] = {}

        # Clock tracking
        self._clock_times: deque[float] = deque(maxlen=CLOCK_PPQN)
        self._current_bpm: float = 0.0
        self._clock_start_time: float = 0.0
        self._clock_running = False

        # Motor control — for expression pedal smoothing
        self._cc_14bit_high: dict[int, int] = {}  # MSB pending LSB

        # Channel filter — optional single-channel filtering
        self._channel_filter: Optional[int] = None

        # I/O
        self._interfaces: list[MIDIInterface] = []
        self._running = False
        self._read_thread: Optional[threading.Thread] = None
        self._msg_queue: queue.Queue[MIDIEvent] = queue.Queue()

    # ── Callback registration ──────────────────────────────────────

    def set_pc_callback(self, callback: Callable[[int, int], None]) -> None:
        """Set callback for Program Change events.

        Args:
            callback: (channel, program_number) called on PC.
        """
        self._pc_callback = callback

    def register_cc(self, cc_number: int,
                    callback: Callable[[int, int], None]) -> None:
        """Register a callback for a specific CC number.

        Args:
            cc_number: 0-127
            callback: (value, channel) called on CC.
        """
        self._cc_callbacks[cc_number] = callback

    def set_clock_callback(self, callback: Callable[[float], None]) -> None:
        """Set callback for detected MIDI clock BPM.

        Args:
            callback: Called with detected BPM on tempo change.
        """
        self._clock_callback = callback

    def set_note_callback(self, callback: Callable[[int, int, int], None]) -> None:
        """Set callback for Note On/Off events.

        Args:
            callback: (note, velocity, channel) called on note on/off.
                Velocity 0 = note off.
        """
        self._note_callback = callback

    def set_channel_filter(self, channel: Optional[int]) -> None:
        """Set an optional MIDI channel filter.

        When set, only MIDI events on the given channel (0-15) will be
        dispatched.  Pass None to disable filtering (omni mode).

        Args:
            channel: MIDI channel 0-15, or None for omni.
        """
        self._channel_filter = channel
        logger.info("MIDI channel filter set to %s",
                     f"channel {channel}" if channel is not None else "omni")

    def set_midi_learn_callback(
        self, callback: Callable[[LearnedMapping], None]
    ) -> None:
        """Set callback for MIDI Learn completion.

        Args:
            callback: Called when a CC is learned during learn mode.
        """
        self._midi_learn_callback = callback

    # ── MIDI Learn ─────────────────────────────────────────────────

    @property
    def learn_mode(self) -> bool:
        return self._learn_mode

    def start_learn(self, target_param: str) -> None:
        """Begin MIDI Learn for a parameter.

        Args:
            target_param: Unique key identifying the parameter (e.g.
                "delay.feedback", "reverb.mix").
        """
        self._learn_mode = True
        self._learn_target = target_param
        logger.info("MIDI Learn started for %s — move a controller", target_param)

    def stop_learn(self) -> None:
        """Exit MIDI Learn mode without assigning."""
        self._learn_mode = False
        self._learn_target = None
        logger.info("MIDI Learn stopped")

    def cancel_learn(self) -> None:
        """Cancel MIDI Learn (alias for stop_learn)."""
        self.stop_learn()

    def get_mapping(self, param_key: str) -> Optional[MIDIMapping]:
        """Get the MIDI mapping for a parameter, if any."""
        return self._mappings.get(param_key)

    def set_mapping(self, param_key: str, mapping: MIDIMapping) -> None:
        """Set a MIDI mapping for a parameter."""
        self._mappings[param_key] = mapping

    def remove_mapping(self, param_key: str) -> None:
        """Remove the MIDI mapping for a parameter."""
        self._mappings.pop(param_key, None)

    def get_all_mappings(self) -> dict[str, MIDIMapping]:
        """Return all current MIDI mappings."""
        return dict(self._mappings)

    # ── Message parsing ────────────────────────────────────────────

    def parse(self, data: bytes) -> Optional[MIDIEvent]:
        """Parse raw MIDI bytes into a structured event.

        Only accepts a single complete message (caller must pre-split).
        For multi-byte messages, pass the full message including status.

        Args:
            data: Complete MIDI message bytes.

        Returns:
            Parsed MIDIEvent or None for unrecognized / real-time
            messages that don't produce events.
        """
        if not data:
            return None

        status = data[0]

        # Real-time messages
        if status >= 0xF8:
            if status == RT_CLOCK:
                self._handle_clock_tick()
                return MIDIEvent(type="clock")
            elif status == RT_START:
                self._handle_clock_start()
                return MIDIEvent(type="start")
            elif status == RT_CONTINUE:
                self._handle_clock_continue()
                return MIDIEvent(type="continue")
            elif status == RT_STOP:
                self._handle_clock_stop()
                return MIDIEvent(type="stop")
            return None  # active sensing, tick, reset — ignored

        # Channel voice messages
        status_type = status & STATUS_TYPE_MASK
        channel = status & CHANNEL_MASK

        match status_type:
            case 0x90:  # Note On
                if len(data) < 3:
                    return None
                return MIDIEvent(
                    type="note_on" if data[2] > 0 else "note_off",
                    channel=channel,
                    note=data[1],
                    velocity=data[2],
                )
            case 0x80:  # Note Off
                if len(data) < 3:
                    return None
                return MIDIEvent(
                    type="note_off",
                    channel=channel,
                    note=data[1],
                    velocity=data[2],
                )
            case 0xB0:  # Control Change
                if len(data) < 3:
                    return None
                return MIDIEvent(
                    type="cc",
                    channel=channel,
                    cc_number=data[1],
                    cc_value=data[2],
                )
            case 0xC0:  # Program Change
                if len(data) < 2:
                    return None
                return MIDIEvent(
                    type="pc",
                    channel=channel,
                    program=data[1],
                )
            case 0xD0:  # Channel Pressure (Aftertouch)
                if len(data) < 2:
                    return None
                return MIDIEvent(
                    type="channel_pressure",
                    channel=channel,
                    velocity=data[1],
                )
            case 0xE0:  # Pitch Bend
                if len(data) < 3:
                    return None
                # 14-bit value
                value = data[1] | (data[2] << 7)
                return MIDIEvent(
                    type="pitch_bend",
                    channel=channel,
                    cc_value=value,  # Reuse field, 0-16383
                )
            case 0xA0:  # Polyphonic Pressure
                if len(data) < 3:
                    return None
                return MIDIEvent(
                    type="poly_pressure",
                    channel=channel,
                    note=data[1],
                    velocity=data[2],
                )
            case _:
                return None

    def dispatch(self, event: MIDIEvent) -> None:
        """Dispatch a parsed MIDI event to registered handlers.

        Args:
            event: Parsed MIDI event.
        """
        # Channel filter: skip events not matching the configured channel
        if self._channel_filter is not None and event.channel != self._channel_filter:
            return

        match event.type:
            case "pc":
                if self._pc_callback:
                    self._pc_callback(event.channel, event.program)
                logger.debug("PC: ch=%d, program=%d", event.channel, event.program)

            case "cc":
                self._dispatch_cc(event)

            case "note_on" | "note_off":
                if self._note_callback:
                    self._note_callback(event.note, event.velocity, event.channel)
                logger.debug("Note: %s %d (vel=%d, ch=%d)",
                             event.type, event.note, event.velocity, event.channel)

            case "pitch_bend":
                logger.debug("Pitch Bend: %d (ch=%d)", event.cc_value, event.channel)

            case "clock" | "start" | "stop" | "continue":
                pass  # Already handled by parse

            case _:
                logger.debug("Unhandled event: %s", event.type)

    def _dispatch_cc(self, event: MIDIEvent) -> None:
        """Handle a Control Change event.

        Handles 14-bit CC (MSB values 0-31 cue LSB values 32-63),
        MIDI Learn, expression pedal, and registered callbacks.
        """
        cc = event.cc_number
        val = event.cc_value

        # ── MIDI Learn ──
        if self._learn_mode and self._learn_target:
            mapping = MIDIMapping(
                cc_number=cc,
                channel=event.channel,
                min_val=0.0,
                max_val=1.0,
            )
            self._mappings[self._learn_target] = mapping
            learned = LearnedMapping(
                cc_number=cc,
                channel=event.channel,
                param_key=self._learn_target,
            )
            if self._midi_learn_callback:
                self._midi_learn_callback(learned)
            logger.info("MIDI Learned: CC %d → %s", cc, self._learn_target)
            self._learn_mode = False  # Auto-exit after learn
            self._learn_target = None
            return

        # ── 14-bit CC (MSB values 0-31 paired with LSB 32-63) ──
        if cc < 32:
            # MSB received — store for potential LSB pairing
            self._cc_14bit_high[cc] = val
            # Fire immediate coarse callback
            cb = self._cc_callbacks.get(cc)
            if cb:
                cb(val, event.channel)
            logger.debug("CC MSB: %d → %d (ch=%d)", cc, val, event.channel)
            return  # Handled; don't fall through to standard dispatch

        elif 32 <= cc <= 63:
            # LSB received — check for pending MSB
            msb_key = cc - 32
            if msb_key in self._cc_14bit_high:
                # We have both halves — compute full 14-bit
                msb = self._cc_14bit_high.pop(msb_key)
                combined_14bit = (msb << 7) | val
                scaled = combined_14bit >> 7  # 0-127 with LSB contribution
                cb = self._cc_callbacks.get(msb_key)
                if cb:
                    cb(scaled, event.channel)
                logger.debug("CC 14-bit: %d → %d (combined %d, scaled %d, ch=%d)",
                             msb_key, scaled, combined_14bit, scaled, event.channel)
                return
            # Orphaned LSB — treat as standard CC
            # Fall through to standard dispatch

        # ── Standard CC dispatch (for orphan LSB or CC 64-127) ──
        cb = self._cc_callbacks.get(cc)
        if cb:
            cb(val, event.channel)
        logger.debug("CC: %d → %d (ch=%d)", cc, val, event.channel)

    # ── Clock sync ─────────────────────────────────────────────────

    def _handle_clock_tick(self) -> None:
        """Process a MIDI clock tick (24 PPQN)."""
        now = time.monotonic()
        self._clock_times.append(now)

        if not self._clock_running:
            return

        # Calculate BPM from the interval between consecutive clock ticks
        if len(self._clock_times) >= 2:
            recent = list(self._clock_times)[-2:]
            interval = recent[1] - recent[0]
            if interval > 0:
                bpm = 60.0 / (interval * CLOCK_PPQN)
                # Only update if significantly different and plausible
                if 20 < bpm < 300:
                    # Smooth with exponential moving average
                    if self._current_bpm == 0:
                        self._current_bpm = bpm
                    else:
                        alpha = 0.3
                        self._current_bpm = (
                            alpha * bpm + (1 - alpha) * self._current_bpm
                        )
                    if self._clock_callback:
                        self._clock_callback(self._current_bpm)

    def _handle_clock_start(self) -> None:
        """MIDI Start — begin clock tracking."""
        self._clock_times.clear()
        self._clock_running = True
        self._clock_start_time = time.monotonic()
        self._current_bpm = 0.0
        logger.info("MIDI clock started")

    def _handle_clock_continue(self) -> None:
        """MIDI Continue — resume clock tracking."""
        self._clock_running = True
        logger.info("MIDI clock continued")

    def _handle_clock_stop(self) -> None:
        """MIDI Stop — pause clock tracking."""
        self._clock_running = False
        logger.info("MIDI clock stopped")

    @property
    def current_bpm(self) -> float:
        """Current detected BPM from MIDI clock, or 0.0 if no clock."""
        return self._current_bpm

    @property
    def clock_running(self) -> bool:
        """Whether MIDI clock is actively being received."""
        return self._clock_running

    def reset_clock(self) -> None:
        """Manually reset MIDI clock state."""
        self._clock_times.clear()
        self._current_bpm = 0.0
        self._clock_running = False

    # ── MIDI output builders ───────────────────────────────────────

    @staticmethod
    def send_cc(cc_number: int, value: int, channel: int = 0) -> bytes:
        """Build a MIDI Control Change message."""
        return bytes([CONTROL_CHANGE | (channel & 0x0F),
                      max(0, min(127, cc_number)),
                      max(0, min(127, value))])

    @staticmethod
    def send_pc(program: int, channel: int = 0) -> bytes:
        """Build a MIDI Program Change message."""
        return bytes([PROGRAM_CHANGE | (channel & 0x0F),
                      max(0, min(127, program))])

    @staticmethod
    def send_note_on(note: int, velocity: int = 127, channel: int = 0) -> bytes:
        """Build a MIDI Note On message."""
        return bytes([NOTE_ON | (channel & 0x0F),
                      max(0, min(127, note)),
                      max(0, min(127, velocity))])

    @staticmethod
    def send_note_off(note: int, velocity: int = 0, channel: int = 0) -> bytes:
        """Build a MIDI Note Off message."""
        return bytes([NOTE_OFF | (channel & 0x0F),
                      max(0, min(127, note)),
                      max(0, min(127, velocity))])

    @staticmethod
    def send_pitch_bend(value: int, channel: int = 0) -> bytes:
        """Build a MIDI Pitch Bend message (0-16383, center 8192)."""
        val = max(0, min(16383, value))
        return bytes([PITCH_BEND | (channel & 0x0F),
                      val & 0x7F,
                      (val >> 7) & 0x7F])

    @staticmethod
    def send_clock_start() -> bytes:
        """Build a MIDI Real-Time Start message."""
        return bytes([RT_START])

    @staticmethod
    def send_clock_stop() -> bytes:
        """Build a MIDI Real-Time Stop message."""
        return bytes([RT_STOP])

    @staticmethod
    def send_clock_tick() -> bytes:
        """Build a MIDI Clock message."""
        return bytes([RT_CLOCK])

    @staticmethod
    def send_sysex(manufacturer_id: int, data: bytes) -> bytes:
        """Build a SysEx message."""
        return bytes([SYS_EXCLUSIVE, manufacturer_id & 0x7F]) + data + bytes([SYS_EXCLUSIVE_END])

    # ── Hardware I/O ───────────────────────────────────────────────

    def start(
        self,
        uart_port: str | None = "/dev/ttyAMA0",
        usb: bool = True,
        usb_port_name: str = "",
    ) -> None:
        """Open MIDI ports and start the read thread.

        Args:
            uart_port: UART device path for 5-pin DIN, or None to skip.
            usb: Whether to attempt USB-MIDI.
            usb_port_name: Filter string for USB port selection.
        """
        self._interfaces = []

        if uart_port:
            uart = UARTMIDI(port=uart_port)
            if uart.open():
                self._interfaces.append(uart)

        if usb:
            usb_midi = USBMIDI(port_name=usb_port_name)
            if usb_midi.open():
                self._interfaces.append(usb_midi)

        if not self._interfaces:
            logger.warning("No MIDI interfaces opened — running in software-only mode")

        self._running = True
        self._read_thread = threading.Thread(
            target=self._read_loop,
            name="midi-read",
            daemon=True,
        )
        self._read_thread.start()
        logger.info("MIDI handler started with %d interface(s)", len(self._interfaces))

    def stop(self) -> None:
        """Stop the read thread and close MIDI ports."""
        self._running = False
        if self._read_thread and self._read_thread.is_alive():
            self._read_thread.join(timeout=2.0)
        for iface in self._interfaces:
            iface.close()
        self._interfaces.clear()
        logger.info("MIDI handler stopped")

    def send(self, msg: bytes) -> None:
        """Send a MIDI message to all open interfaces.

        Args:
            msg: Raw MIDI message bytes.
        """
        for iface in self._interfaces:
            iface.send(msg)

    def _read_loop(self) -> None:
        """Background thread: poll all interfaces and queue events."""
        while self._running:
            for iface in self._interfaces:
                try:
                    msgs = iface.read(timeout=0.001)
                except Exception as e:
                    logger.warning("Read error on %s: %s", iface.name, e)
                    continue
                for msg in msgs:
                    event = self.parse(msg)
                    if event:
                        self.dispatch(event)
            # Small sleep to prevent busy-wait
            time.sleep(0.0005)

    @property
    def running(self) -> bool:
        return self._running

    @property
    def interfaces(self) -> list[MIDIInterface]:
        return list(self._interfaces)

    @property
    def interface_names(self) -> list[str]:
        return [i.name for i in self._interfaces]