"""
builtin_mic.py — G1 built-in microphone (UDP multicast capture)
================================================================
The G1 humanoid's on-board microphone is published by the Unitree firmware
as an RTP-like UDP multicast stream on 239.168.123.161:5555, carrying
16 kHz mono int16 PCM. Any host on the robot's 192.168.123.0/24 network
can join the group and read the audio — no extra SDK call required.

This module intentionally has no dependency on pyaudio, pulseaudio, or the
unitree_sdk2py package. Joining the multicast group is all that's needed.

Usage:
    from Voice.builtin_mic import BuiltinMic
    mic = BuiltinMic()
    mic.start()
    try:
        chunk = mic.read_chunk(1024)   # 512 samples, 32 ms at 16 kHz
        ...
    finally:
        mic.stop()

Ported from Project/Sanad/voice/audio_io.py (Sanad's production implementation).
"""

from __future__ import annotations

import os
import socket
import struct
import subprocess
import sys
import threading
import time
from typing import Optional

# Load defaults from Config/config_Voice.json::mic_udp so they can be tuned
# without editing code. Falls back to the hardcoded literals below if the
# config isn't reachable (e.g., when imported from a test harness).
_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _PROJECT_DIR not in sys.path:
    sys.path.insert(0, _PROJECT_DIR)
try:
    from Core.config_loader import load_config
    _mic_udp = (load_config("Voice") or {}).get("mic_udp", {}) or {}
except Exception:
    _mic_udp = {}

DEFAULT_GROUP        = str(_mic_udp.get("group", "239.168.123.161"))
DEFAULT_PORT         = int(_mic_udp.get("port", 5555))
DEFAULT_BUF_MAX      = int(_mic_udp.get("buffer_max_bytes", 64_000))     # ~2 s of 16 kHz mono int16
DEFAULT_READ_TIMEOUT = float(_mic_udp.get("read_timeout_sec", 0.04))     # budget per read_chunk call
SAMPLE_RATE          = 16_000                                             # hardware rate — do not change


def _find_g1_local_ip() -> str:
    """
    Return the host IPv4 on the G1's internal 192.168.123.0/24 network.
    Required by IP_ADD_MEMBERSHIP so the kernel knows which NIC to join on.
    """
    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(
        "BuiltinMic: no interface on 192.168.123.0/24 — "
        "host is not on the G1's internal network"
    )


class BuiltinMic:
    """
    G1 on-board microphone over UDP multicast.

    Thread-safe: a background daemon thread receives datagrams into an
    internal ring buffer; `read_chunk(n)` pulls the next `n` bytes or
    blocks up to `read_timeout` before returning zeros.
    """

    sample_rate = SAMPLE_RATE

    def __init__(
        self,
        group: str = DEFAULT_GROUP,
        port: int = DEFAULT_PORT,
        buf_max: int = DEFAULT_BUF_MAX,
        read_timeout: float = DEFAULT_READ_TIMEOUT,
    ):
        self._group        = group
        self._port         = port
        self._buf_max      = buf_max
        self._read_timeout = read_timeout
        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:
        if self._running:
            return
        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, name="builtin_mic_rx",
        )
        self._thread.start()
        print(f"  [BuiltinMic] joined {self._group}:{self._port} on {local_ip}")

    def _recv_loop(self) -> None:
        while self._running:
            try:
                data, _ = self._sock.recvfrom(4096)
                with self._lock:
                    self._buf.extend(data)
                    # ring-buffer: drop oldest when we'd exceed buf_max
                    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:
        """
        Return exactly `num_bytes` of 16 kHz mono int16 PCM.

        Waits up to `read_timeout` for that many bytes to be available.
        If the buffer is still short after the timeout, returns whatever
        is available padded with silence. Never blocks forever.
        """
        deadline = time.time() + self._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 read_seconds(self, seconds: float) -> bytes:
        """
        Convenience: capture `seconds` of audio and return as bytes.
        Blocks for the full duration (not a real-time producer).
        """
        num_bytes = int(seconds * self.sample_rate * 2)  # 2 bytes/sample (int16)
        out = bytearray()
        chunk_bytes = 1024
        while len(out) < num_bytes:
            out.extend(self.read_chunk(min(chunk_bytes, num_bytes - len(out))))
        return bytes(out)

    def flush(self) -> None:
        """Drop all buffered audio (e.g. after the robot spoke)."""
        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
        if self._thread is not None:
            self._thread.join(timeout=1.5)
            self._thread = None


# ────────────────────────────────────────────────────────────────
# Standalone test — capture 3 s and print energy stats
# ────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import array

    print("BuiltinMic standalone test — capturing 3 s from G1...")
    mic = BuiltinMic()
    mic.start()
    time.sleep(0.3)  # let the receiver thread warm up
    raw = mic.read_seconds(3.0)
    mic.stop()

    samples = array.array("h", raw)
    if not samples:
        print("  FAIL — got zero samples")
    else:
        mn = min(samples); mx = max(samples)
        mean_abs = sum(abs(s) for s in samples) / len(samples)
        print(f"  samples={len(samples)}  min={mn}  max={mx}  mean|s|={mean_abs:.0f}")
        if mean_abs > 30:
            print("  OK — mic is capturing audio")
        else:
            print("  WARN — signal very low, check G1 audio service is running")
