#!/usr/bin/env python3
"""MIDI Mapper Daemon — main runtime for the Raspberry Pi RT Audio Mixer.

Reads MIDI events from connected USB controllers via ALSA sequencer
or rtmidi, applies mapping rules, and routes mapped parameter changes
to the mixer engine and JACK MIDI output ports.

Usage:
  midi-mapper                          # Run with default session
  midi-mapper --session live-set       # Use named mapping session
  midi-mapper --list-devices           # List connected MIDI devices
  midi-mapper --no-jack                # Don't create JACK MIDI ports
  midi-mapper --port 0                 # Only listen on ALSA seq client 0
"""

from __future__ import annotations

import argparse
import logging
import os
import signal
import sys
import time
from pathlib import Path

# Add project root to path when run as script
_proj_root = Path(__file__).resolve().parent.parent
if str(_proj_root) not in sys.path:
    sys.path.insert(0, str(_proj_root))

from src.midi.types import (
    MIDIMessage,
    MIDIMessageType,
    MIDIMapping,
)
from src.midi.midi_engine import MIDIEngine
from src.midi.midi_clock import MIDIClock
from src.midi.jack_midi_bridge import JACKMIDIBridge, midi_cc, midi_note_on, midi_note_off
from src.midi.device_discovery import discover_all, MIDIDevice
from src.midi.mapping_store import load_mappings, DEFAULT_SESSION_NAME
from src.mixer import ParameterRegistry

logger = logging.getLogger(__name__)

_running = True


def _handle_signal(signum, frame):
    global _running
    logger.info("Received signal %d, shutting down...", signum)
    _running = False


def _setup_logging(verbose: bool = False, quiet: bool = False):
    if quiet:
        level = logging.WARNING
    elif verbose:
        level = logging.DEBUG
    else:
        level = logging.INFO
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
        datefmt="%H:%M:%S",
    )


def _list_devices():
    """Print connected MIDI devices and exit."""
    devices = discover_all()
    if not devices:
        print("No MIDI devices found.")
        print("Make sure your USB MIDI controller is plugged in.")
        print("Check with: ls /dev/snd/midi*  or  cat /proc/asound/seq/clients")
        return

    print(f"\nFound {len(devices)} MIDI device(s):\n")
    for d in devices:
        vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "n/a"
        print(f"  {d.display_name}")
        print(f"    Device node: {d.device_node}")
        print(f"    USB VID:PID: {vidpid}")
        print(f"    ALSA seq client: {d.alsa_client_id or 'n/a'}")
        print(f"    Serial: {d.serial or 'n/a'}")
        print()


def _print_parameter_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage):
    """Default callback: log parameter changes to console."""
    ch_str = f"CH{mapping.param_channel}" if mapping.param_channel >= 0 else "Master"
    logger.info(
        "PARAM | %s %s = %.3f  (MIDI CC%d=%d)",
        ch_str,
        mapping.param_type.value,
        value,
        mapping.controller,
        raw_msg.value if raw_msg.value is not None else -1,
    )


def _jack_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage, bridge: JACKMIDIBridge):
    """Forward mapped events to JACK MIDI ports."""
    ch = mapping.channel
    cc = mapping.controller
    midi_val = int(value * 127)
    midi_val = max(0, min(127, midi_val))

    # Route to different JACK MIDI ports based on parameter category
    from src.midi.types import ParameterCategory
    port_idx = 0
    if mapping.param_type.value.startswith("eq_"):
        port_idx = 1
    elif mapping.param_type.value.startswith("comp_"):
        port_idx = 1
    elif mapping.param_type.value.startswith("fx_"):
        port_idx = 2
    elif mapping.param_type.value in ("play", "stop", "record", "loop"):
        port_idx = 3

    if cc >= 0:
        raw = midi_cc(ch, cc, midi_val)
        bridge.send_midi(port_idx, raw)


def _open_midi_input(device: MIDIDevice) -> object | None:
    """Open a MIDI input port for a device.

    Tries rtmidi first, falls back to ALSA sequencer.
    Returns the open port object or None.
    """
    # Try rtmidi
    try:
        import rtmidi
        midi_in = rtmidi.MidiIn()
        # Find the port matching our device
        ports = midi_in.get_ports()
        for i, port_name in enumerate(ports):
            if device.alsa_client_name and device.alsa_client_name.lower() in port_name.lower():
                midi_in.open_port(i)
                logger.info("Opened rtmidi port %d: %s", i, port_name)
                return midi_in
            if device.product and device.product.lower() in port_name.lower():
                midi_in.open_port(i)
                logger.info("Opened rtmidi port %d: %s", i, port_name)
                return midi_in
        # If no match, open first available
        if ports:
            midi_in.open_port(0)
            logger.info("Opened rtmidi port 0: %s", ports[0])
            return midi_in
    except ImportError:
        logger.debug("rtmidi not available")
    except Exception as exc:
        logger.warning("rtmidi open failed: %s", exc)

    # Try ALSA sequencer
    try:
        import alsaseq
        client = alsaseq.SequencerClient("rpi-mixer-input", client_type=alsaseq.SEQ_CLIENT_DUPLEX)
        src = (device.alsa_client_id, 0)
        dest = (client.client_id, 0)
        port = client.create_port(
            f"input_{device.alsa_client_id}",
            caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
            type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
        )
        client.connect_ports(src, dest)
        logger.info("Opened ALSA seq port from client %d", device.alsa_client_id)
        return client
    except ImportError:
        logger.debug("alsaseq not available")
    except Exception as exc:
        logger.warning("ALSA seq open failed: %s", exc)

    return None


def _read_midi_rtmidi(midi_in, engine: MIDIEngine, clock: MIDIClock):
    """Poll rtmidi for events and feed them to the engine."""
    msg = midi_in.get_message()
    while msg is not None:
        data, timestamp = msg
        if len(data) >= 1:
            _dispatch_raw_midi(data, timestamp, engine, clock)
        msg = midi_in.get_message()


def _dispatch_raw_midi(data: tuple | list, timestamp: float, engine: MIDIEngine, clock: MIDIClock):
    """Parse raw MIDI bytes and dispatch to engine or clock."""
    if not data:
        return
    status = data[0]

    # System realtime messages → clock
    if status >= 0xF8:
        clock.process_message(status)
        return

    # Channel voice messages → engine
    if status >= 0x80 and status < 0xF0:
        msg_type = MIDIMessageType.from_status(status)
        channel = status & 0x0F
        controller = data[1] if len(data) > 1 else None
        value = data[2] if len(data) > 2 else None

        midi_msg = MIDIMessage(
            msg_type=msg_type,
            channel=channel,
            controller=controller,
            value=value,
            timestamp=time.monotonic(),
        )
        engine.process_event(midi_msg)


def _main_loop(args):
    """Main event loop: discover devices, open inputs, process MIDI."""
    global _running

    # Load mappings
    mappings = load_mappings(args.session)
    logger.info("Loaded %d mappings from session '%s'", len(mappings), args.session)

    # Create engine
    engine = MIDIEngine()
    engine.set_mappings(mappings)
    engine.on_mapped(_print_parameter_callback)

    # Create clock
    clock = MIDIClock()
    clock.on_transport(lambda ev: logger.info("Transport: %s", ev))
    clock.on_tempo_change(lambda bpm, raw, stable: logger.info("Tempo: %.1f BPM (stable=%s)", bpm, stable))

    # Create JACK bridge
    bridge = JACKMIDIBridge()
    if not args.no_jack:
        bridge.start()
        engine.on_mapped(lambda m, v, r: _jack_callback(m, v, r, bridge))
        logger.info("JACK MIDI bridge active")

    # Create parameter registry
    registry = ParameterRegistry()
    engine.on_mapped(lambda m, v, r: registry.set_value(m.param_type, m.param_channel, v))

    # Discover and open devices
    devices = discover_all()
    logger.info("Found %d MIDI device(s)", len(devices))

    midi_inputs = []
    for dev in devices:
        if args.port >= 0 and dev.alsa_client_id != args.port:
            continue
        inp = _open_midi_input(dev)
        if inp:
            midi_inputs.append((dev, inp))

    if not midi_inputs:
        logger.warning("No MIDI inputs opened. Is a controller plugged in?")
        if not args.allow_no_device:
            logger.error("No MIDI devices available. Use --list-devices to check, or --allow-no-device to run anyway.")
            return

    # Main loop
    engine.start()
    logger.info("MIDI mapper running. Press Ctrl+C to stop.")

    stats_interval = 10.0
    last_stats = time.monotonic()

    while _running:
        # Poll each input
        for dev, inp in midi_inputs:
            try:
                if hasattr(inp, 'get_message'):  # rtmidi
                    _read_midi_rtmidi(inp, engine, clock)
                # ALSA seq events are handled differently (callback-based)
            except Exception as exc:
                logger.error("Error reading from %s: %s", dev.display_name, exc)

        # Periodic stats
        now = time.monotonic()
        if now - last_stats >= stats_interval:
            s = engine.stats
            logger.info(
                "Stats: %d events, %d mapped, %.1f ev/s, %d mappings active",
                s["events_total"], s["events_mapped"],
                s["events_per_second"], s["mappings_active"],
            )
            last_stats = now

        time.sleep(0.001)  # ~1kHz poll

    # Cleanup
    engine.stop()
    if bridge.is_active:
        bridge.stop()
    for dev, inp in midi_inputs:
        try:
            if hasattr(inp, 'close_port'):
                inp.close_port()
        except Exception:
            pass

    logger.info("MIDI mapper shut down cleanly.")


# ── Main ────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="MIDI Mapper Daemon — RPi Audio Mixer MIDI engine",
    )
    parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session to load")
    parser.add_argument("--list-devices", action="store_true", help="List connected MIDI devices and exit")
    parser.add_argument("--no-jack", action="store_true", help="Don't create JACK MIDI output ports")
    parser.add_argument("--port", type=int, default=-1, help="Only listen on specific ALSA seq client ID")
    parser.add_argument("--allow-no-device", action="store_true", help="Run even if no MIDI devices are found")
    parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
    parser.add_argument("--quiet", "-q", action="store_true", help="Minimal logging")
    args = parser.parse_args()

    _setup_logging(args.verbose, args.quiet)

    if args.list_devices:
        _list_devices()
        return

    # Signal handling
    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    _main_loop(args)


if __name__ == "__main__":
    main()
