"""System-level audio configuration for the Pi Multi-FX Pedal.

Manages ALSA / JACK / I2S setup on RPi 4B:
- I2S DAC/ADC initialization with known-good overlays
- JACK audio server configuration with PREEMPT_RT priority
- ALSA device discovery and naming
- JACK port auto-connect for FX pipeline
- XRun monitoring
- Round-trip latency measurement
"""

from __future__ import annotations

import logging
import os
import re
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Callable
from typing import TYPE_CHECKING, Optional

import numpy as np

if TYPE_CHECKING:
    from ..dsp.pipeline import AudioPipeline

logger = logging.getLogger(__name__)

# ── Known-good I2S overlays ───────────────────────────────────────
# Map: short key → (dtoverlay line, expected ALSA card index, description)
# Card index 0 assumes the I2S HAT is the only sound card; adjust if HDMI/USB audio present.
I2S_CONFIGS: dict[str, tuple[str, int, str]] = {
    "audioinjector": (
        "dtoverlay=audioinjector-wm8731",
        0,
        "AudioInjector Stereo HAT — Cirrus Logic CS5343 ADC + CS4344 DAC",
    ),
    "pcm1808_pcm5102": (
        "dtoverlay=audiosense-pi",
        0,
        "PCM1808 ADC + PCM5102 DAC breakout combo (budget, ~$12)",
    ),
    "iqaudio_codec": (
        "dtoverlay=iqaudio-codec",
        0,
        "IQaudio Codec Zero — ADC+DAC, 48 kHz max (BCKL limitation)",
    ),
    "justboom": (
        "dtoverlay=justboom-dac",
        0,
        "JustBoom DAC/ADC HAT — full 192 kHz/24-bit",
    ),
    "wm8731": (
        "dtoverlay=wm8731",
        0,
        "Waveshare / PHAT DAC — WM8731 codec, 48 kHz",
    ),
}

# ── USB audio interface profiles (non-I2S, e.g. Focusrite) ────────
# Map: short key → (description, expected capture ports, expected playback ports)
FOCUSRITE_PROFILES: dict[str, tuple[str, int, int]] = {
    "focusrite_2i2_3gen": (
        "Focusrite Scarlett 2i2 3rd gen — USB, 2-in / 2-out, 48 kHz, 24-bit",
        2,
        2,
    ),
    "focusrite_2i2_1gen": (
        "Focusrite Scarlett 2i2 1st gen — USB, 2-in / 2-out, 48 kHz, 24-bit",
        2,
        2,
    ),
}

# ── JACK latency profiles ─────────────────────────────────────────
# 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT
# 48 kHz /  64 frames = 1.33 ms buffer  → aggressive, may xrun on RPi 4B
LATENCY_PROFILES: dict[str, dict] = {
    "standard": {
        "period": 256,
        "nperiods": 2,
        "rate": 48000,
        "rt_priority": 70,
    },
    "low": {
        "period": 64,
        "nperiods": 2,
        "rate": 48000,
        "rt_priority": 80,
    },
    "stable": {
        "period": 512,
        "nperiods": 2,
        "rate": 48000,
        "rt_priority": 60,
    },
}

# ── System paths ──────────────────────────────────────────────────
CONFIG_TXT = Path("/boot/config.txt")
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
LIMITS_CONF = Path("/etc/security/limits.d/99-audio.conf")


# ═══════════════════════════════════════════════════════════════════
# Configuration
# ═══════════════════════════════════════════════════════════════════


@dataclass
class AudioConfig:
    """Audio system configuration.

    Attributes:
        hat_type:        I2S HAT type key from I2S_CONFIGS.
        profile:         Latency profile key from LATENCY_PROFILES.
        mode:            I/O routing mode: "mono" (default, hw:0,0) or
                         "stereo_4cm" (Focusrite 2i2 4-cable-method, 2ch I/O).
        input_device:    ALSA hardware device for capture (e.g. hw:0,0).
        output_device:   ALSA hardware device for playback (e.g. hw:0,0).
        jack_enabled:    Whether to start the JACK server.
        auto_connect:    Auto-connect JACK capture→FX pipeline→playback ports.
        xrun_warn_only:  If True, log xruns instead of restarting JACK.
    """

    hat_type: str = "audioinjector"
    profile: str = "standard"
    mode: str = "mono"
    input_device: str = "hw:0,0"
    output_device: str = "hw:0,0"
    jack_enabled: bool = True
    auto_connect: bool = True
    xrun_warn_only: bool = True

    @property
    def latency_profile(self) -> dict:
        """Resolve the latency profile dict."""
        p = LATENCY_PROFILES.get(self.profile)
        if p is None:
            logger.warning("Unknown profile %r, falling back to standard", self.profile)
            return LATENCY_PROFILES["standard"]
        return p

    @property
    def overlay_line(self) -> Optional[str]:
        """Get the dtoverlay line for the configured HAT, or None."""
        entry = I2S_CONFIGS.get(self.hat_type)
        if entry is None:
            logger.warning("Unknown HAT type %r", self.hat_type)
            return None
        return entry[0]

    @property
    def jack_device_arg(self) -> str:
        """JACK ALSA device argument (output device drives JACK master)."""
        return f"d{self.output_device}"

    @property
    def capture_channels(self) -> int:
        """Number of JACK capture channels (1 for mono, 2 for stereo_4cm)."""
        return 2 if self.mode == "stereo_4cm" else 1

    @property
    def playback_channels(self) -> int:
        """Number of JACK playback channels (1 for mono, 2 for stereo_4cm)."""
        return 2 if self.mode == "stereo_4cm" else 1

    @property
    def focusrite_profile(self) -> Optional[tuple[str, int, int]]:
        """Resolve Focusrite profile from hat_type, or None if not a Focusrite."""
        return FOCUSRITE_PROFILES.get(self.hat_type)


# ═══════════════════════════════════════════════════════════════════
# Audio system manager
# ═══════════════════════════════════════════════════════════════════


class AudioSystem:
    """Manages the audio subsystem: I2S, ALSA, and JACK.

    Usage:
        sys = AudioSystem(AudioConfig(hat_type="audioinjector"))
        sys.setup_i2s()
        sys.start_jack()
    """

    def __init__(self, config: Optional[AudioConfig] = None) -> None:
        self.config = config or AudioConfig()
        self._tempo_bpm: float = 120.0
        self._tempo_source: str = "default"  # "default", "midi_clock", "manual"
        self._midi_clock_enabled: bool = False
        self._tempo_callback: Optional[Callable[[float], None]] = None

    # ──────────────────────────────────────────────────────────────
    # I2S overlay management
    # ──────────────────────────────────────────────────────────────

    def setup_i2s(self, reboot_hint: bool = True) -> bool:
        """Verify the I2S overlay is present in config.txt.

        If missing, appends the line (dry-run unless run as root).

        Args:
            reboot_hint:  If True, log a message that a reboot is needed.

        Returns:
            True if overlay is already active or was successfully added.
        """
        overlay = self.config.overlay_line
        if overlay is None:
            return False

        # Check if it exists
        try:
            txt = CONFIG_TXT.read_text()
        except (OSError, PermissionError) as exc:
            logger.error("Cannot read %s: %s", CONFIG_TXT, exc)
            return False

        if overlay in txt:
            logger.info("I2S overlay already enabled: %s", overlay)
            return True

        # Append
        try:
            with CONFIG_TXT.open("a") as f:
                f.write(f"\n# Pi Multi-FX Pedal — {self.config.hat_type}\n{overlay}\n")
            logger.info("Appended %s to %s", overlay, CONFIG_TXT)
        except PermissionError:
            logger.warning(
                "Need root to edit %s. "
                "Run: echo '%s' | sudo tee -a %s",
                CONFIG_TXT, overlay, CONFIG_TXT,
            )
            return False
        except OSError as exc:
            logger.error("Failed to write %s: %s", CONFIG_TXT, exc)
            return False

        if reboot_hint:
            logger.info("Reboot required for I2S overlay to take effect")
        return True

    @staticmethod
    def get_active_overlay() -> Optional[str]:
        """Detect which I2S overlay is currently set in config.txt.

        Returns the overlay line if found, or None.
        """
        try:
            txt = CONFIG_TXT.read_text()
        except OSError:
            return None
        for match in re.finditer(r"^dtoverlay=(.+)", txt, re.MULTILINE):
            return match.group(0)
        return None

    # ──────────────────────────────────────────────────────────────
    # JACK server lifecycle
    # ──────────────────────────────────────────────────────────────

    def start_jack(self, timeout: int = 10) -> bool:
        """Start the JACK audio server with optimal settings.

        Blocks up to *timeout* seconds waiting for JACK to report ready.

        Args:
            timeout:  Max seconds to wait for JACK to start.

        Returns:
            True if JACK is running.
        """
        if not self.config.jack_enabled:
            logger.info("JACK disabled in config")
            return False

        # Already running?
        if _jack_is_running():
            logger.info("JACK already running")
            return True

        profile = self.config.latency_profile
        jack_env = os.environ.copy()
        jack_env["JACK_NO_AUDIO_RESERVATION"] = "1"
        # Scarlett 2i2 needs 2 channels even in mono mode
        n_in = max(2, self.config.capture_channels)
        n_out = max(2, self.config.playback_channels)
        # Use chrt to enforce SCHED_FIFO — JACK's -P flag can silently fail
        cmd = [
            "chrt", "-f", str(profile['rt_priority']),
            "jackd",
            "-P", str(profile['rt_priority']),
            "-d", "alsa",
            "-d", self.config.output_device,
            "-r", str(profile['rate']),
            "-p", str(profile['period']),
            "-n", str(profile['nperiods']),
            "-i", str(n_in),
            "-o", str(n_out),
        ]
        logger.info("Starting JACK: %s", " ".join(cmd))

        try:
            proc = subprocess.Popen(
                cmd,
                env=jack_env,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        except FileNotFoundError:
            logger.error("jackd not found — install jackd2")
            return False

        # Wait for readiness via jack_wait
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            time.sleep(0.3)
            if _jack_is_running():
                logger.info(
                    "JACK started: period=%d, nperiods=%d, rate=%d",
                    profile["period"], profile["nperiods"], profile["rate"],
                )
                # Auto-connect ports if enabled
                if self.config.auto_connect:
                    self.connect_fx_ports()
                return True

        # Timed out — check for common issues
        poll = proc.poll()
        if poll is not None:
            logger.error("jackd exited early with code %d", poll)
        else:
            logger.error("JACK failed to become ready within %ds", timeout)
            proc.kill()
        return False

    def stop_jack(self) -> None:
        """Gracefully stop the JACK server."""
        try:
            subprocess.run(
                ["killall", "jackd"],
                capture_output=True, timeout=5,
            )
            logger.info("JACK stopped")
        except subprocess.TimeoutExpired:
            logger.warning("JACK stop timed out — sending SIGKILL")
            subprocess.run(["killall", "-9", "jackd"], capture_output=True)
        except FileNotFoundError:
            pass

    def restart_jack(self, timeout: int = 10) -> bool:
        """Restart JACK server."""
        self.stop_jack()
        time.sleep(0.5)
        return self.start_jack(timeout=timeout)

    # ──────────────────────────────────────────────────────────────
    # Channel mapping helpers
    # ──────────────────────────────────────────────────────────────

    @staticmethod
    def channel_mapping_help(mode: str = "mono") -> list[dict]:
        """Describe the JACK port-to-pipeline channel routing.

        Args:
            mode:  ``"mono"`` or ``"stereo_4cm"``.

        Returns:
            List of dicts with keys: capture_port, pipeline_input, pipeline_output,
            playback_port, description.

        Raises:
            ValueError:  If *mode* is not recognised.
        """
        if mode == "mono":
            return [{
                "capture_port": "system:capture_1",
                "pipeline_input": "fx_in:input_0",
                "pipeline_output": "fx_out:output_0",
                "playback_port": "system:playback_1",
                "description": "Guitar → FX chain → Output",
            }]
        elif mode == "stereo_4cm":
            return [
                {
                    "capture_port": "system:capture_1",
                    "pipeline_input": "fx_in:input_0",
                    "pipeline_output": "fx_out:output_0",
                    "playback_port": "system:playback_1",
                    "description": "Guitar (Input 1) → Pre-amp FX chain → Amp Input (Send)",
                },
                {
                    "capture_port": "system:capture_2",
                    "pipeline_input": "fx_in:input_1",
                    "pipeline_output": "fx_out:output_1",
                    "playback_port": "system:playback_2",
                    "description": "FX Send (Input 2) → Post-amp FX chain → Amp FX Return",
                },
            ]
        else:
            raise ValueError(f"Unknown mode: {mode!r}")

    # ──────────────────────────────────────────────────────────────
    # JACK port connections
    # ──────────────────────────────────────────────────────────────

    def connect_fx_ports(self) -> None:
        """Connect JACK ports for the FX pipeline.

        Mono (default):
          system:capture_1  →  fx_in:input_0     (guitar → FX chain)
          fx_out:output_0   →  system:playback_1  (FX chain → output)

        Stereo 4CM (Focusrite 2i2):
          system:capture_1  →  fx_in:input_0     (guitar → pre-amp path)
          system:capture_2  →  fx_in:input_1     (FX send → post-amp path)
          fx_out:output_0   →  system:playback_1 (pre-amp → amp input)
          fx_out:output_1   →  system:playback_2 (post-amp → FX return)

        This is a no-op if jack_connect is unavailable (not in PATH)
        or if any of the target ports don't exist yet.
        """
        # Mono: duplicate processed signal to both L/R channels
        # so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
        mono_connections = [
            ("system:capture_1", "fx_in:input_0"),
            ("fx_out:output_0", "system:playback_1"),
            ("fx_out:output_0", "system:playback_2"),
        ]
        stereo_4cm_connections = [
            ("system:capture_1", "fx_in:input_0"),
            ("system:capture_2", "fx_in:input_1"),
            ("fx_out:output_0", "system:playback_1"),
            ("fx_out:output_1", "system:playback_2"),
        ]

        connections = (
            stereo_4cm_connections
            if self.config.mode == "stereo_4cm"
            else mono_connections
        )
        for src, dst in connections:
            try:
                subprocess.run(
                    ["jack_connect", src, dst],
                    capture_output=True, text=True, timeout=3,
                )
                logger.debug("Connected %s → %s", src, dst)
            except FileNotFoundError:
                logger.warning("jack_connect not found — skipping port connections")
                return
            except subprocess.TimeoutExpired:
                logger.warning("Timeout connecting %s → %s", src, dst)

    # ──────────────────────────────────────────────────────────────
    # ALSA device listing
    # ──────────────────────────────────────────────────────────────

    @staticmethod
    def list_devices() -> list[dict]:
        """List available ALSA audio devices with structured info.

        Returns:
            List of dicts with keys: card, device, name, type, description.
        """
        try:
            result = subprocess.run(
                ["aplay", "-l"],
                capture_output=True, text=True, timeout=5,
            )
        except FileNotFoundError:
            return []

        devices: list[dict] = []
        for line in result.stdout.splitlines():
            m = re.match(
                r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
                line,
            )
            if m:
                devices.append({
                    "card": int(m.group(1)),
                    "device": int(m.group(3)),
                    "short_name": m.group(2).strip(),
                    "full_name": m.group(4).strip(),
                    "type": "playback",
                })

        # Also capture devices
        try:
            result = subprocess.run(
                ["arecord", "-l"],
                capture_output=True, text=True, timeout=5,
            )
        except FileNotFoundError:
            pass
        else:
            for line in result.stdout.splitlines():
                m = re.match(
                    r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
                    line,
                )
                if m:
                    devices.append({
                        "card": int(m.group(1)),
                        "device": int(m.group(3)),
                        "short_name": m.group(2).strip(),
                        "full_name": m.group(4).strip(),
                        "type": "capture",
                    })

        return devices

    # ──────────────────────────────────────────────────────────────
    # XRun monitoring
    # ──────────────────────────────────────────────────────────────

    @staticmethod
    def read_xrun_count() -> Optional[int]:
        """Read JACK xrun counter from jack_showtime.

        Returns:
            Number of xruns since JACK started, or None if unavailable.
        """
        try:
            result = subprocess.run(
                ["jack_showtime", "-c"],
                capture_output=True, text=True, timeout=3,
            )
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return None

        m = re.search(r"xruns\s*=\s*(\d+)", result.stdout)
        if m:
            return int(m.group(1))
        return None

    def monitor_xruns(self, duration: int = 300, interval: int = 10) -> dict:
        """Monitor xruns over a test period.

        Args:
            duration:  Test duration in seconds (default 300 = 5 min).
            interval:  Poll interval in seconds.

        Returns:
            Dict with xrun_total, xrun_rate_per_min, duration, stable.
        """
        logger.info("Starting xrun monitor: duration=%ds, interval=%ds", duration, interval)

        start_xruns = self.read_xrun_count()
        if start_xruns is None:
            logger.warning("Cannot read xrun count — jack_showtime not available")
            return {"xrun_total": None, "xrun_rate_per_min": None, "duration": duration, "stable": None}

        deadline = time.monotonic() + duration
        last_val = start_xruns
        peak = 0

        while time.monotonic() < deadline:
            time.sleep(interval)
            val = self.read_xrun_count()
            if val is None:
                continue
            delta = val - last_val
            if delta > 0:
                logger.warning("XRUN detected: +%d (total: %d)", delta, val)
            peak = max(peak, delta)
            last_val = val

        total = last_val - start_xruns
        rate = total / (duration / 60.0)
        stable = total == 0
        logger.info(
            "XRun monitor complete: %d total (%.2f/min), stable=%s",
            total, rate, stable,
        )
        return {
            "xrun_total": total,
            "xrun_rate_per_min": round(rate, 2),
            "duration": duration,
            "stable": stable,
        }

    # ──────────────────────────────────────────────────────────────
    # Round-trip latency measurement
    # ──────────────────────────────────────────────────────────────

    @staticmethod
    def measure_roundtrip_latency(samples: int = 8, timeout: int = 30) -> Optional[float]:
        """Measure JACK round-trip latency using jack_iodelay.

        Args:
            samples:  Number of measurements to take.
            timeout:  Max seconds per measurement.

        Returns:
            Average round-trip latency in milliseconds, or None on failure.
        """
        try:
            subprocess.run(
                ["jack_iodelay", "--help"],
                capture_output=True, timeout=3,
            )
        except FileNotFoundError:
            logger.warning("jack_iodelay not found — install jack-tools")
            return None

        latencies: list[float] = []
        for i in range(samples):
            try:
                result = subprocess.run(
                    ["jack_iodelay"],
                    capture_output=True, text=True, timeout=timeout,
                )
                m = re.search(r"round.trip\s+latency[:\s]+([\d.]+)\s*ms", result.stdout, re.IGNORECASE)
                if m:
                    val = float(m.group(1))
                    latencies.append(val)
                    logger.info("Latency measurement %d/%d: %.2f ms", i + 1, samples, val)
                else:
                    logger.debug("jack_iodelay output did not match: %s", result.stdout[:200])
            except subprocess.TimeoutExpired:
                logger.warning("Latency measurement %d timed out", i + 1)
            except subprocess.CalledProcessError as exc:
                logger.warning("Latency measurement %d failed: %s", i + 1, exc)

        if not latencies:
            logger.error("No valid latency measurements")
            return None

        avg = sum(latencies) / len(latencies)
        logger.info(
            "Round-trip latency: avg=%.2f ms, min=%.2f ms, max=%.2f ms (n=%d)",
            avg, min(latencies), max(latencies), len(latencies),
        )
        return avg

    # ──────────────────────────────────────────────────────────────
    # Systemd service management
    # ──────────────────────────────────────────────────────────────

    @staticmethod
    def systemd_service_content(config: AudioConfig) -> str:
        """Generate a systemd unit file for JACK.

        Args:
            config:  Audio configuration for the service parameters.

        Returns:
            Systemd unit file content as a string.
        """
        profile = config.latency_profile
        exec_start = (
            f"/usr/bin/chrt -f {profile['rt_priority']} "
            f"/usr/bin/jackd -P{profile['rt_priority']} "
            f"-p{profile['period']} -n{profile['nperiods']} "
            f"-r{profile['rate']} -dalsa -d{config.output_device} "
            f"-i{config.capture_channels} -o{config.playback_channels}"
        )
        return f"""[Unit]
Description=JACK Audio Server — Pi Multi-FX Pedal
After=sound.target network.target
Wants=multi-fx-pedal.target

[Service]
Type=simple
User=pi
ExecStart={exec_start}
Restart=on-failure
RestartSec=5
LimitRTPRIO=99
LimitMEMLOCK=infinity

[Install]
WantedBy=multi-user.target
"""

    def install_systemd_service(self) -> bool:
        """Install the JACK systemd service.

        Requires root.

        Returns:
            True if installed successfully.
        """
        content = self.systemd_service_content(self.config)
        try:
            JACK_SERVICE_PATH.write_text(content)
            subprocess.run(
                ["systemctl", "daemon-reload"],
                capture_output=True, timeout=10,
            )
            logger.info("Installed systemd service: %s", JACK_SERVICE_PATH)
            return True
        except PermissionError:
            logger.warning(
                "Need root to install systemd service. "
                "Run setup_audio.sh as root, or manually: "
                "sudo cp the unit file to %s && sudo systemctl daemon-reload",
                JACK_SERVICE_PATH,
            )
            return False
        except OSError as exc:
            logger.error("Failed to install service: %s", exc)
            return False


    # ──────────────────────────────────────────────────────────────
    # MIDI clock sync (tempo integration for time-based FX)
    # ──────────────────────────────────────────────────────────────

    @property
    def tempo_bpm(self) -> float:
        """Current tempo in BPM for time-based effects (delay, reverb)."""
        return self._tempo_bpm

    @tempo_bpm.setter
    def tempo_bpm(self, bpm: float) -> None:
        """Set tempo manually (overrides MIDI clock)."""
        self._tempo_source = "manual"
        self._tempo_bpm = max(20.0, min(300.0, bpm))
        if self._tempo_callback:
            self._tempo_callback(self._tempo_bpm)
        logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm)

    @property
    def tempo_source(self) -> str:
        return self._tempo_source

    def set_tempo_callback(self, callback: Callable[[float], None]) -> None:
        """Register a callback for tempo changes.

        Args:
            callback: Called with new BPM value when tempo changes.
        """
        self._tempo_callback = callback

    def set_tempo_from_midi_clock(self, bpm: float) -> None:
        """Update tempo from MIDI clock.

        Called by MIDIHandler's clock callback. Only updates if MIDI
        clock sync is enabled.

        Args:
            bpm: Detected BPM from MIDI clock (20-300).
        """
        if not self._midi_clock_enabled:
            return
        bpm = max(20.0, min(300.0, bpm))
        if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock":
            self._tempo_bpm = bpm
            self._tempo_source = "midi_clock"
            if self._tempo_callback:
                self._tempo_callback(bpm)
            logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm)

    def enable_midi_clock_sync(self, enabled: bool = True) -> None:
        """Enable or disable MIDI clock sync.

        When enabled, tempo follows MIDI clock. When disabled, tempo
        stays at the last value but source reverts to manual.

        Args:
            enabled: Whether to follow MIDI clock.
        """
        self._midi_clock_enabled = enabled
        if not enabled and self._tempo_source == "midi_clock":
            self._tempo_source = "manual"
        logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled")


# ═══════════════════════════════════════════════════════════════════
# Internal helpers
# ═══════════════════════════════════════════════════════════════════


def _jack_is_running() -> bool:
    """Check if JACK is running via process list or PID file."""
    try:
        result = subprocess.run(
            ["pgrep", "-x", "jackd"],
            capture_output=True, timeout=3,
        )
        return result.returncode == 0
    except FileNotFoundError:
        # pgrep not available — fall back to pidof
        try:
            result = subprocess.run(
                ["pidof", "jackd"],
                capture_output=True, timeout=3,
            )
            return result.returncode == 0
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return False
    except subprocess.TimeoutExpired:
        return False


# ═══════════════════════════════════════════════════════════════════
# JACK audio client (real-time I/O with pipeline)
# ═══════════════════════════════════════════════════════════════════


class JackAudioClient:
    """JACK client that streams audio through the DSP pipeline.

    Owns JACK ports and a real-time process callback. Handles both
    mono (1ch I/O) and stereo 4CM (2ch I/O) routing transparently.

    Data flow:

        Mono:
          capture_1 → pipeline.process() → playback_1

        Stereo 4CM:
          capture_1 (guitar)  → pipeline.process((2, N)) → playback_1 (send)
          capture_2 (return)                              → playback_2 (return)

    The pipeline's ``process()`` method handles routing internally
    based on ``pipeline.routing_mode`` (``"mono"`` or ``"4cm"``).

    .. warning:: The process callback runs in a real-time thread.
       No blocking calls (alloc, I/O, locks) are allowed inside it.
    """

    def __init__(
        self,
        pipeline: AudioPipeline,
        client_name: str = "pi-multifx",
        input_channels: int = 2,
        output_channels: int = 2,
    ) -> None:
        self._pipeline = pipeline
        self._client_name = client_name
        self._input_channels = input_channels
        self._output_channels = output_channels

        # JACK client — created on start()
        self._client: Optional[object] = None
        self._inports: list[object] = []
        self._outports: list[object] = []
        self._active = False

        # Scratch buffers (reused per callback to avoid allocations)
        self._in_buf: Optional[np.ndarray] = None
        self._out_buf: Optional[np.ndarray] = None

    # ── Lifecycle ──────────────────────────────────────────────────

    def start(self) -> None:
        """Create a JACK client, register ports, activate processing."""
        if self._active:
            logger.debug("JackAudioClient already active")
            return

        import jack  # lazy import — only needed at runtime

        try:
            self._client = jack.Client(self._client_name)
        except jack.JackOpenError as exc:
            logger.error("Cannot open JACK client: %s", exc)
            return

        # Register capture ports
        self._inports.clear()
        suffix_map = {1: "", 2: ("_1", "_2")}
        suffixes = suffix_map.get(self._input_channels, tuple(f"_{i+1}" for i in range(self._input_channels)))
        for i in range(self._input_channels):
            suffix = suffixes[i] if isinstance(suffixes, tuple) else ""
            port = self._client.inports.register(f"capture{suffix}")
            self._inports.append(port)

        # Register playback ports
        self._outports.clear()
        suffixes = suffix_map.get(self._output_channels, tuple(f"_{i+1}" for i in range(self._output_channels)))
        for i in range(self._output_channels):
            suffix = suffixes[i] if isinstance(suffixes, tuple) else ""
            port = self._client.outports.register(f"playback{suffix}")
            self._outports.append(port)

        logger.info(
            "JACK client '%s': %d in port(s), %d out port(s)",
            self._client_name, self._input_channels, self._output_channels,
        )

        # Register process callback
        self._client.set_process_callback(self._process_callback)

        # Pre-allocate scratch buffers
        block_size = self._client.blocksize
        self._in_buf = np.zeros((self._input_channels, block_size), dtype=np.float32)
        self._out_buf = np.zeros((self._output_channels, block_size), dtype=np.float32)

        self._client.activate()
        self._active = True
        logger.info("JACK client activated (blocksize=%d)", block_size)

        # Explicitly connect ports to system (JACK auto-connect is unreliable)
        self._connect_ports()

    def stop(self) -> None:
        """Deactivate and close the JACK client."""
        if not self._active or self._client is None:
            return
        try:
            self._client.deactivate()
        except Exception as exc:
            logger.warning("JACK deactivate error: %s", exc)
        try:
            self._client.close()
        except Exception as exc:
            logger.warning("JACK close error: %s", exc)
        self._client = None
        self._inports.clear()
        self._outports.clear()
        self._active = False
        self._in_buf = None
        self._out_buf = None
        logger.info("JACK client stopped")

    # ── Port connection helpers ──────────────────────────────────

    def _connect_ports(self) -> None:
        """Explicitly connect our ports to the system physical ports.

        JACK's auto-connect is unreliable across systemd service restarts.
        This ensures audio always flows even when the JACK server was
        started before our client.
        """
        if self._client is None:
            return

        # Connect each capture port to the corresponding system capture
        for i, port in enumerate(self._inports):
            suffix = f"capture_{i + 1}" if self._input_channels > 1 else "capture_1"
            sys_port_name = f"system:{suffix}"
            try:
                sys_ports = self._client.get_ports(sys_port_name)
                if sys_ports:
                    self._client.connect(sys_ports[0], port)
                    logger.debug("Connected %s -> %s", sys_port_name, port.name)
            except Exception as exc:
                logger.warning("Failed to connect %s -> %s: %s",
                               sys_port_name, port.name, exc)

        # Connect each playback port to the corresponding system playback.
        # In mono mode (single outport), duplicate to both L/R channels
        # so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
        for i, port in enumerate(self._outports):
            if self._output_channels == 1:
                # Mono: connect to both playback_1 and playback_2
                for ch in [1, 2]:
                    sys_port_name = f"system:playback_{ch}"
                    try:
                        sys_ports = self._client.get_ports(sys_port_name)
                        if sys_ports:
                            self._client.connect(port, sys_ports[0])
                            logger.debug("Mono: connected %s -> %s", port.name, sys_port_name)
                    except Exception as exc:
                        logger.warning("Failed to connect %s -> %s: %s",
                                       port.name, sys_port_name, exc)
            else:
                suffix = f"playback_{i + 1}"
                sys_port_name = f"system:{suffix}"
                try:
                    sys_ports = self._client.get_ports(sys_port_name)
                    if sys_ports:
                        self._client.connect(port, sys_ports[0])
                        logger.debug("Connected %s -> %s", port.name, sys_port_name)
                except Exception as exc:
                    logger.warning("Failed to connect %s -> %s: %s",
                                   port.name, sys_port_name, exc)

    # ── Runtime reconfiguration ────────────────────────────────────

    def set_channel_count(self, input_channels: int, output_channels: int) -> None:
        """Change channel count (restart required)."""
        if self._active:
            logger.info("Changing channel count — restarting JACK client")
            self.stop()
        self._input_channels = input_channels
        self._output_channels = output_channels
        self.start()

    def set_pipeline(self, pipeline: AudioPipeline) -> None:
        """Swap pipeline reference (safe while JACK is stopped)."""
        self._pipeline = pipeline

    # ── Process callback (REAL-TIME — no blocking calls) ──────────

    def _process_callback(self, frames: int) -> None:
        """Real-time JACK process callback.

        Reads audio from capture ports, runs through pipeline,
        writes results to playback ports.

        Args:
            frames: Number of samples in this block.
        """
        # ── Ensure scratch buffers are large enough ──────────────
        if self._in_buf is None or self._in_buf.shape[1] < frames:
            self._in_buf = np.zeros((self._input_channels, frames), dtype=np.float32)
        if self._out_buf is None or self._out_buf.shape[1] < frames:
            self._out_buf = np.zeros((self._output_channels, frames), dtype=np.float32)

        # ── Read from capture ports ──────────────────────────────
        for ch in range(self._input_channels):
            port_buf = self._inports[ch].get_array()
            self._in_buf[ch, :frames] = port_buf[:frames]
        # DEBUG: always log
        import os
        os.system('echo "CB frames=' + str(frames) + '" >> /tmp/debug_audio.log')

        # ── Run through pipeline ─────────────────────────────────
        if self._input_channels == 1:
            audio_in = self._in_buf[0, :frames]
        else:
            audio_in = self._in_buf[:, :frames]

        try:
            audio_out = self._pipeline.process(audio_in)
        except Exception as exc:
            # Never let an exception escape the RT callback
            logger.error("Pipeline error: %s", str(exc))
            import traceback
            logger.error("Traceback:\n%s", traceback.format_exc())
            audio_out = np.zeros(frames, dtype=np.float32) if self._output_channels == 1 else \
                np.zeros((self._output_channels, frames), dtype=np.float32)

        # ── Write to playback ports ──────────────────────────────
        if self._output_channels == 1:
            self._outports[0].get_array()[:frames] = audio_out[:frames]
        else:
            for ch in range(self._output_channels):
                self._outports[ch].get_array()[:frames] = audio_out[ch, :frames]