"""Hardware-agnostic audio I/O for Sanad voice pipelines.

Provides a uniform Mic / Speaker interface so the model layer (Gemini
today, or any future alternative) doesn't need to know which physical
audio path is active. Pick a pairing via `AudioIO.from_profile()`:

    builtin            → G1 UDP multicast mic + AudioClient.PlayStream
    anker              → Anker PowerConf USB mic + speaker (PyAudio)
    hollyland_builtin  → Hollyland wireless mic + G1 built-in speaker

Mics deliver int16 mono PCM at 16 kHz.
Speakers accept int16 mono PCM plus a `source_rate` and resample
internally if the hardware runs at a different rate.

Usage:

    audio = AudioIO.from_profile("builtin", audio_client=ac)
    audio.start()
    try:
        chunk = audio.mic.read_chunk(1024)         # mic
        audio.speaker.begin_stream()               # speaker
        audio.speaker.send_chunk(pcm_24k, 24000)
        audio.speaker.wait_finish()
    finally:
        audio.stop()
"""

from __future__ import annotations

import json
import socket
import struct
import subprocess
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Optional, Union

import numpy as np

try:
    import pyaudio
    _HAS_PYAUDIO = True
except ImportError:
    pyaudio = None
    _HAS_PYAUDIO = False

from Project.Sanad.core.config_loader import section as _cfg_section
from Project.Sanad.core.logger import get_logger

log = get_logger("audio_io")

_MIC_CFG = _cfg_section("voice", "mic_udp")
_SP_CFG = _cfg_section("voice", "speaker")

TARGET_MIC_RATE = 16_000

_MCAST_GRP = _MIC_CFG.get("group", "239.168.123.161")
_MCAST_PORT = _MIC_CFG.get("port", 5555)
_MIC_BUF_MAX = _MIC_CFG.get("buffer_max_bytes", 64_000)
_MIC_READ_TIMEOUT = _MIC_CFG.get("read_timeout_sec", 0.04)

PCMLike = Union[bytes, bytearray, memoryview, np.ndarray]


def _find_g1_local_ip() -> str:
    """Find the host IPv4 address on the G1's internal 192.168.123.0/24 network."""
    out = subprocess.run(
        ["ip", "-4", "-o", "addr"], capture_output=True, text=True,
    ).stdout
    for line in out.splitlines():
        for tok in line.split():
            if tok.startswith("192.168.123."):
                return tok.split("/")[0]
    raise RuntimeError("no 192.168.123.x interface found")


def _resample_int16(pcm: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
    if src_rate == dst_rate or pcm.size == 0:
        return pcm.astype(np.int16, copy=False)
    target_len = max(1, int(len(pcm) * dst_rate / src_rate))
    return np.interp(
        np.linspace(0, len(pcm), target_len, endpoint=False),
        np.arange(len(pcm)),
        pcm.astype(np.float64),
    ).astype(np.int16)


def _as_int16_array(pcm: PCMLike) -> np.ndarray:
    if isinstance(pcm, np.ndarray):
        return pcm.astype(np.int16, copy=False)
    return np.frombuffer(bytes(pcm), dtype=np.int16)


# ─── Protocols ────────────────────────────────────────────

class Mic(ABC):
    sample_rate: int = TARGET_MIC_RATE

    @abstractmethod
    def start(self) -> None: ...
    @abstractmethod
    def read_chunk(self, num_bytes: int) -> bytes: ...
    @abstractmethod
    def flush(self) -> None: ...
    @abstractmethod
    def stop(self) -> None: ...


class Speaker(ABC):
    @abstractmethod
    def begin_stream(self) -> None: ...

    @abstractmethod
    def send_chunk(self, pcm: PCMLike, source_rate: int) -> None:
        """Queue PCM for playback. `source_rate` is the sample rate of `pcm`."""

    @abstractmethod
    def wait_finish(self) -> None: ...
    @abstractmethod
    def stop(self) -> None: ...

    @property
    @abstractmethod
    def interrupted(self) -> bool: ...

    @property
    def total_sent_sec(self) -> float:
        return 0.0


# ─── G1 built-in (UDP mic + AudioClient speaker) ──────────

class BuiltinMic(Mic):
    """G1 robot's on-board mic published over UDP multicast."""

    sample_rate = TARGET_MIC_RATE

    def __init__(self, group: str = _MCAST_GRP, port: int = _MCAST_PORT,
                 buf_max: int = _MIC_BUF_MAX):
        self._group = group
        self._port = port
        self._buf_max = buf_max
        self._sock: Optional[socket.socket] = None
        self._buf = bytearray()
        self._lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None

    def start(self) -> None:
        local_ip = _find_g1_local_ip()
        self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._sock.bind(("", self._port))
        mreq = struct.pack(
            "4s4s",
            socket.inet_aton(self._group),
            socket.inet_aton(local_ip),
        )
        self._sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
        self._sock.settimeout(1.0)
        self._running = True
        self._thread = threading.Thread(target=self._recv_loop, daemon=True)
        self._thread.start()
        log.info("BuiltinMic joined %s:%d on %s", self._group, self._port, local_ip)

    def _recv_loop(self) -> None:
        while self._running:
            try:
                data, _ = self._sock.recvfrom(4096)
                with self._lock:
                    self._buf.extend(data)
                    if len(self._buf) > self._buf_max:
                        del self._buf[:len(self._buf) - self._buf_max]
            except socket.timeout:
                continue
            except Exception:
                if self._running:
                    time.sleep(0.01)

    def read_chunk(self, num_bytes: int) -> bytes:
        deadline = time.time() + _MIC_READ_TIMEOUT
        while time.time() < deadline:
            with self._lock:
                if len(self._buf) >= num_bytes:
                    chunk = bytes(self._buf[:num_bytes])
                    del self._buf[:num_bytes]
                    return chunk
            time.sleep(0.003)
        with self._lock:
            avail = len(self._buf)
            if avail > 0:
                chunk = bytes(self._buf[:avail])
                del self._buf[:avail]
                return chunk + b"\x00" * (num_bytes - avail)
        return b"\x00" * num_bytes

    def flush(self) -> None:
        with self._lock:
            self._buf.clear()

    def stop(self) -> None:
        self._running = False
        if self._sock is not None:
            try:
                self._sock.close()
            except Exception:
                pass
            self._sock = None


class BuiltinSpeaker(Speaker):
    """G1 robot's built-in speaker via AudioClient.PlayStream (16 kHz mono)."""

    HARDWARE_RATE = 16_000

    def __init__(self, audio_client: Any, app_name: Optional[str] = None):
        self._ac = audio_client
        try:
            self._ac.SetVolume(100)
        except Exception:
            log.warning("BuiltinSpeaker.SetVolume failed")
        self._app_name = app_name or _SP_CFG.get("app_name", "sanad")
        self._begin_pause = _SP_CFG.get("begin_stream_pause_sec", 0.15)
        self._finish_margin = _SP_CFG.get("wait_finish_margin_sec", 0.3)
        self._stop_flag = threading.Event()
        self._stream_id: Optional[str] = None
        self._total_sent = 0.0
        self._play_start = 0.0

    def _stop_play_api(self) -> None:
        try:
            from unitree_sdk2py.g1.audio.g1_audio_api import (
                ROBOT_API_ID_AUDIO_STOP_PLAY,
            )
            self._ac._Call(
                ROBOT_API_ID_AUDIO_STOP_PLAY,
                json.dumps({"app_name": self._app_name}),
            )
        except Exception:
            log.warning("BuiltinSpeaker AUDIO_STOP_PLAY failed")

    def begin_stream(self) -> None:
        self._stop_flag.clear()
        self._stop_play_api()
        time.sleep(self._begin_pause)
        self._stream_id = f"s_{int(time.time() * 1000)}"
        self._total_sent = 0.0
        self._play_start = time.time()

    def send_chunk(self, pcm: PCMLike, source_rate: int) -> None:
        if self._stop_flag.is_set():
            return
        arr = _as_int16_array(pcm)
        if arr.size < 10:
            return
        hw = _resample_int16(arr, source_rate, self.HARDWARE_RATE)
        self._ac.PlayStream(self._app_name, self._stream_id, hw.tobytes())
        self._total_sent += len(hw) / self.HARDWARE_RATE

    def wait_finish(self) -> None:
        elapsed = time.time() - self._play_start
        remaining = self._total_sent - elapsed + self._finish_margin
        waited = 0.0
        while waited < remaining and not self._stop_flag.is_set():
            time.sleep(0.1)
            waited += 0.1
        self._stop_play_api()

    def stop(self) -> None:
        self._stop_flag.set()
        self._stop_play_api()

    @property
    def interrupted(self) -> bool:
        return self._stop_flag.is_set()

    @property
    def total_sent_sec(self) -> float:
        return self._total_sent


# ─── PyAudio-backed mic/speaker ───────────────────────────

class _PyAudioMic(Mic):
    """Shared base for PulseAudio/ALSA input — matches device by name pattern."""

    sample_rate = TARGET_MIC_RATE

    def __init__(self, device_pattern: str, label: str,
                 frames_per_buffer: int = 512):
        if not _HAS_PYAUDIO:
            raise RuntimeError(f"{label}Mic requires pyaudio")
        self._device_pattern = device_pattern
        self._label = label
        self._frames_per_buffer = frames_per_buffer
        self._pa: Optional["pyaudio.PyAudio"] = None
        self._stream = None
        self._running = False
        self._buf = bytearray()
        self._lock = threading.Lock()
        self._thread: Optional[threading.Thread] = None

    def _resolve_device_index(self) -> Optional[int]:
        if self._pa is None:
            return None
        patterns = [p.strip().lower()
                    for p in self._device_pattern.split(",") if p.strip()]
        for i in range(self._pa.get_device_count()):
            info = self._pa.get_device_info_by_index(i)
            if info.get("maxInputChannels", 0) <= 0:
                continue
            name_lower = str(info.get("name", "")).lower()
            if any(n in name_lower for n in patterns):
                return i
        return None

    def start(self) -> None:
        self._pa = pyaudio.PyAudio()
        idx = self._resolve_device_index()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=1,
            rate=self.sample_rate,
            input=True,
            input_device_index=idx,
            frames_per_buffer=self._frames_per_buffer,
        )
        self._running = True
        self._thread = threading.Thread(target=self._recv_loop, daemon=True)
        self._thread.start()
        log.info("%sMic started (device_index=%s)", self._label, idx)

    def _recv_loop(self) -> None:
        while self._running:
            try:
                data = self._stream.read(
                    self._frames_per_buffer, exception_on_overflow=False,
                )
                with self._lock:
                    self._buf.extend(data)
                    if len(self._buf) > _MIC_BUF_MAX:
                        del self._buf[:len(self._buf) - _MIC_BUF_MAX]
            except Exception:
                if self._running:
                    time.sleep(0.01)

    def read_chunk(self, num_bytes: int) -> bytes:
        deadline = time.time() + _MIC_READ_TIMEOUT
        while time.time() < deadline:
            with self._lock:
                if len(self._buf) >= num_bytes:
                    chunk = bytes(self._buf[:num_bytes])
                    del self._buf[:num_bytes]
                    return chunk
            time.sleep(0.003)
        with self._lock:
            avail = len(self._buf)
            if avail > 0:
                chunk = bytes(self._buf[:avail])
                del self._buf[:avail]
                return chunk + b"\x00" * (num_bytes - avail)
        return b"\x00" * num_bytes

    def flush(self) -> None:
        with self._lock:
            self._buf.clear()

    def stop(self) -> None:
        self._running = False
        if self._stream is not None:
            try:
                self._stream.stop_stream()
                self._stream.close()
            except Exception:
                pass
            self._stream = None
        if self._pa is not None:
            try:
                self._pa.terminate()
            except Exception:
                pass
            self._pa = None


class AnkerMic(_PyAudioMic):
    def __init__(self):
        super().__init__(device_pattern="powerconf,anker", label="Anker")


class HollylandMic(_PyAudioMic):
    def __init__(self):
        super().__init__(
            device_pattern="hollyland,wireless_microphone",
            label="Hollyland",
        )


class _PyAudioSpeaker(Speaker):
    """PulseAudio/ALSA output — opens a fresh output stream per begin_stream()."""

    def __init__(self, device_pattern: str, label: str):
        if not _HAS_PYAUDIO:
            raise RuntimeError(f"{label}Speaker requires pyaudio")
        self._device_pattern = device_pattern
        self._label = label
        self._pa: Optional["pyaudio.PyAudio"] = None
        self._stream = None
        self._stream_rate: Optional[int] = None
        self._stop_flag = threading.Event()
        self._total_sent = 0.0

    def _resolve_device_index(self) -> Optional[int]:
        if self._pa is None:
            return None
        patterns = [p.strip().lower()
                    for p in self._device_pattern.split(",") if p.strip()]
        for i in range(self._pa.get_device_count()):
            info = self._pa.get_device_info_by_index(i)
            if info.get("maxOutputChannels", 0) <= 0:
                continue
            name_lower = str(info.get("name", "")).lower()
            if any(n in name_lower for n in patterns):
                return i
        return None

    def _open_stream(self, rate: int) -> None:
        idx = self._resolve_device_index()
        self._stream = self._pa.open(
            format=pyaudio.paInt16,
            channels=1,
            rate=rate,
            output=True,
            output_device_index=idx,
        )
        self._stream_rate = rate
        log.info("%sSpeaker output opened (device_index=%s, rate=%d)",
                 self._label, idx, rate)

    def begin_stream(self) -> None:
        self._stop_flag.clear()
        self._total_sent = 0.0
        if self._pa is None:
            self._pa = pyaudio.PyAudio()

    def send_chunk(self, pcm: PCMLike, source_rate: int) -> None:
        if self._stop_flag.is_set():
            return
        arr = _as_int16_array(pcm)
        if arr.size < 10:
            return
        if self._pa is None:
            self._pa = pyaudio.PyAudio()
        if self._stream is None or self._stream_rate != source_rate:
            if self._stream is not None:
                try:
                    self._stream.stop_stream()
                    self._stream.close()
                except Exception:
                    pass
                self._stream = None
            self._open_stream(source_rate)
        try:
            self._stream.write(arr.tobytes())
            self._total_sent += len(arr) / source_rate
        except Exception as exc:
            log.warning("%sSpeaker write failed: %s", self._label, exc)

    def wait_finish(self) -> None:
        if self._stream is not None:
            try:
                self._stream.stop_stream()
                self._stream.close()
            except Exception:
                pass
            self._stream = None
            self._stream_rate = None

    def stop(self) -> None:
        self._stop_flag.set()
        self.wait_finish()

    @property
    def interrupted(self) -> bool:
        return self._stop_flag.is_set()

    @property
    def total_sent_sec(self) -> float:
        return self._total_sent


class AnkerSpeaker(_PyAudioSpeaker):
    def __init__(self):
        super().__init__(device_pattern="powerconf,anker", label="Anker")


# ─── Factory ──────────────────────────────────────────────

_PROFILE_ALIASES = {
    "builtin": "builtin",
    "g1_builtin": "builtin",
    "g1": "builtin",
    "anker": "anker",
    "anker_powerconf": "anker",
    "hollyland": "hollyland_builtin",
    "hollyland_builtin": "hollyland_builtin",
}

SUPPORTED_PROFILES = ("builtin", "anker", "hollyland_builtin")


@dataclass
class AudioIO:
    mic: Mic
    speaker: Speaker
    profile_id: str = field(default="builtin")

    def start(self) -> None:
        self.mic.start()

    def stop(self) -> None:
        try:
            self.speaker.stop()
        except Exception:
            log.warning("AudioIO speaker.stop failed", exc_info=True)
        try:
            self.mic.stop()
        except Exception:
            log.warning("AudioIO mic.stop failed", exc_info=True)

    @classmethod
    def from_profile(
        cls,
        profile_id: str,
        *,
        audio_client: Optional[Any] = None,
    ) -> "AudioIO":
        """Build an AudioIO for the requested profile.

        `audio_client` is the initialised `unitree_sdk2py` `AudioClient` and
        is required for any profile that speaks through the G1's on-board
        speaker (`builtin`, `hollyland_builtin`).
        """
        raw = (profile_id or "").strip().lower()
        resolved = _PROFILE_ALIASES.get(raw)
        if resolved is None:
            raise ValueError(
                f"unknown audio profile {profile_id!r}; "
                f"supported: {', '.join(SUPPORTED_PROFILES)}"
            )

        if resolved == "builtin":
            if audio_client is None:
                raise ValueError(
                    "profile 'builtin' requires audio_client (G1 AudioClient)"
                )
            return cls(
                mic=BuiltinMic(),
                speaker=BuiltinSpeaker(audio_client),
                profile_id=resolved,
            )
        if resolved == "anker":
            return cls(
                mic=AnkerMic(),
                speaker=AnkerSpeaker(),
                profile_id=resolved,
            )
        if resolved == "hollyland_builtin":
            if audio_client is None:
                raise ValueError(
                    "profile 'hollyland_builtin' uses the G1 speaker — "
                    "requires audio_client"
                )
            return cls(
                mic=HollylandMic(),
                speaker=BuiltinSpeaker(audio_client),
                profile_id=resolved,
            )
        raise AssertionError(f"unhandled resolved profile: {resolved!r}")
