"""Media ducking — fade external audio during TTS playback.

Uses pactl (PulseAudio CLI) to control sink-input volumes through PipeWire's
PulseAudio compatibility layer. When duck() is called, all active sink-inputs
(Firefox, Spotify, etc.) have their volumes stepped down over a configurable
fade period. unduck() restores them.

We use pactl as a subprocess rather than pulsectl-asyncio because PipeWire's
PA compat layer silently drops volume-set operations from pulsectl's native
protocol connection, while pactl (using libpulse) works reliably.

If pactl is not installed or the PulseAudio socket is unavailable, all
operations silently no-op — TTS still works, just without ducking.
"""

import asyncio
import re
import shutil
import sys
from dataclasses import dataclass, field

_HAS_PACTL = shutil.which("pactl") is not None


@dataclass
class _SavedStream:
    """Original volume state for a single sink-input."""

    index: int
    app_name: str
    volume_pct: int  # percentage (0-100+), per PulseAudio's volume scale


@dataclass
class MediaDucker:
    """Async PulseAudio volume controller for media ducking.

    Fade-out steps down all external sink-input volumes over `fade_out_ms`.
    Fade-in restores saved volumes over `fade_in_ms`. Both use ~10 steps
    for smooth animation.

    All errors are caught and logged — never blocks TTS playback.
    """

    fade_out_ms: int = 500
    fade_in_ms: int = 1000
    _is_ducked: bool = field(default=False, init=False)
    _saved_streams: list[_SavedStream] = field(default_factory=list, init=False)
    _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False)

    async def duck(self) -> None:
        """Fade down all external audio streams. No-op if already ducked."""
        if not _HAS_PACTL:
            return
        async with self._lock:
            if self._is_ducked:
                return
            try:
                await self._do_duck()
                self._is_ducked = True
            except Exception as e:
                print(f"  Media duck failed (non-fatal): {e}", file=sys.stderr)
                # Rollback partially-ducked streams to avoid the "volume ratchet"
                try:
                    await self._do_unduck()
                except Exception:
                    pass
                self._saved_streams.clear()

    async def unduck(self) -> None:
        """Restore all audio streams to original volumes. No-op if not ducked."""
        if not _HAS_PACTL:
            return
        async with self._lock:
            if not self._is_ducked:
                return
            try:
                await self._do_unduck()
            except Exception as e:
                print(f"  Media unduck failed (non-fatal): {e}", file=sys.stderr)
            finally:
                self._is_ducked = False
                self._saved_streams.clear()

    async def _pactl_set_volume(self, sink_input_idx: int, volume_pct: int) -> None:
        """Set a sink-input's volume via pactl subprocess."""
        proc = await asyncio.create_subprocess_exec(
            "pactl", "set-sink-input-volume", str(sink_input_idx), f"{volume_pct}%",
            stdout=asyncio.subprocess.DEVNULL,
            stderr=asyncio.subprocess.DEVNULL,
        )
        await proc.wait()

    async def _list_sink_inputs(self) -> list[_SavedStream]:
        """Parse pactl list sink-inputs to get index, app name, and volume."""
        proc = await asyncio.create_subprocess_exec(
            "pactl", "list", "sink-inputs",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.DEVNULL,
        )
        stdout, _ = await proc.communicate()
        if proc.returncode != 0:
            return []

        streams = []
        text = stdout.decode(errors="replace")

        # Split into per-sink-input blocks
        blocks = re.split(r"^Sink Input #", text, flags=re.MULTILINE)
        for block in blocks[1:]:  # skip preamble before first match
            # Extract index
            idx_match = re.match(r"(\d+)", block)
            if not idx_match:
                continue
            idx = int(idx_match.group(1))

            # Extract volume percentage (take first channel)
            vol_match = re.search(r"Volume:.*?(\d+)%", block)
            vol_pct = int(vol_match.group(1)) if vol_match else 100

            # Extract app name
            app_match = re.search(r'application\.name\s*=\s*"([^"]*)"', block)
            app_name = app_match.group(1) if app_match else "unknown"

            streams.append(_SavedStream(index=idx, app_name=app_name, volume_pct=vol_pct))

        return streams

    async def _do_duck(self) -> None:
        """Internal: enumerate sink-inputs and step volumes to zero."""
        streams = await self._list_sink_inputs()
        if not streams:
            print("  Media duck: no sink-inputs found", file=sys.stderr)
            return

        self._saved_streams = streams
        apps = [s.app_name for s in streams]
        print(
            f"  Media duck: fading {len(streams)} streams ({', '.join(apps)})",
            file=sys.stderr,
        )

        # Step down in ~10 steps
        n_steps = 10
        step_delay = self.fade_out_ms / 1000.0 / n_steps

        for step in range(1, n_steps + 1):
            fraction = 1.0 - (step / n_steps)  # 0.9 → 0.0
            tasks = []
            for s in self._saved_streams:
                target_pct = max(int(s.volume_pct * fraction), 0)
                tasks.append(self._pactl_set_volume(s.index, target_pct))
            await asyncio.gather(*tasks, return_exceptions=True)
            await asyncio.sleep(step_delay)

    async def _do_unduck(self) -> None:
        """Internal: restore saved volumes with fade-in."""
        if not self._saved_streams:
            return

        print(
            f"  Media unduck: restoring {len(self._saved_streams)} streams",
            file=sys.stderr,
        )

        # Step up in ~10 steps
        n_steps = 10
        step_delay = self.fade_in_ms / 1000.0 / n_steps

        for step in range(1, n_steps + 1):
            fraction = step / n_steps  # 0.1 → 1.0
            tasks = []
            for s in self._saved_streams:
                target_pct = int(s.volume_pct * fraction)
                tasks.append(self._pactl_set_volume(s.index, target_pct))
            await asyncio.gather(*tasks, return_exceptions=True)
            await asyncio.sleep(step_delay)
