#!/usr/bin/env python3
"""
G1 AUDIO DEVICES — PulseAudio source/sink enumeration + Hollyland auto-setup.

Pure-Python wrapper around `pactl`. No PulseAudio Python bindings required.
Safe to run on workstation or on the robot.

Capabilities:
  list              — list every PulseAudio source and sink
  find <keyword>    — find a source/sink by substring (case-insensitive)
  auto-hollyland    — detect Hollyland wireless mic, unmute, set default, volume 100%
  quick-test <idx>  — record 2 s from source <idx> via parec and report stats
  mute <idx>        — set-source-mute 1
  unmute <idx>      — set-source-mute 0
  volume <idx> <%>  — set-source-volume <pct>%

See voice_note.txt for why parec is preferred over PyAudio on the robot.

Usage:
  python3 g1_audio_devices.py list
  python3 g1_audio_devices.py find hollyland
  python3 g1_audio_devices.py auto-hollyland
  python3 g1_audio_devices.py quick-test 3
"""

from __future__ import annotations

import argparse
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable


HOLLYLAND_KEYWORDS = ("hollyland", "wireless_microphone", "shenzhen_hollyland")
ANKER_KEYWORDS = ("anker", "powerconf")


@dataclass
class PulseDevice:
    index: int
    name: str
    description: str
    state: str
    mute: bool | None = None
    volume_pct: int | None = None


def _run(cmd: list[str]) -> tuple[int, str, str]:
    try:
        p = subprocess.run(cmd, capture_output=True, text=True)
    except FileNotFoundError:
        tool = cmd[0]
        print(
            f"error: `{tool}` not on PATH. Install PulseAudio tools "
            f"(e.g. `sudo apt install pulseaudio-utils`) or run this on the robot.",
            file=sys.stderr,
        )
        sys.exit(127)
    return p.returncode, p.stdout, p.stderr


def _list(kind: str) -> list[PulseDevice]:
    """kind = 'sources' or 'sinks'."""
    rc, out, _ = _run(["pactl", "list", "short", kind])
    devices: list[PulseDevice] = []
    if rc != 0:
        return devices
    for line in out.splitlines():
        parts = line.split("\t")
        if len(parts) < 4:
            continue
        idx, name, _driver, _format = parts[0], parts[1], parts[2], parts[3]
        state = parts[4] if len(parts) > 4 else ""
        devices.append(PulseDevice(int(idx), name, "", state))
    # Enrich with mute + volume from `pactl list {sources,sinks}` (long form).
    rc, long_out, _ = _run(["pactl", "list", kind])
    if rc != 0:
        return devices
    blocks = long_out.split("\n\n")
    for block in blocks:
        lines = block.strip().splitlines()
        if not lines:
            continue
        try:
            header_idx = int(lines[0].split("#")[-1].strip())
        except ValueError:
            continue
        mute = None
        volume = None
        desc = ""
        for ln in lines:
            s = ln.strip()
            if s.startswith("Mute:"):
                mute = s.endswith("yes")
            elif s.startswith("Volume:"):
                # "Volume: front-left: 65536 / 100% / 0.00 dB, ..."
                pct_token = None
                for tok in s.split():
                    if tok.endswith("%") and tok[:-1].lstrip("-").isdigit():
                        pct_token = tok
                        break
                if pct_token:
                    try:
                        volume = int(pct_token.rstrip("%"))
                    except ValueError:
                        pass
            elif s.startswith("Description:"):
                desc = s.split(":", 1)[1].strip()
        for d in devices:
            if d.index == header_idx:
                d.mute = mute
                d.volume_pct = volume
                if desc and not d.description:
                    d.description = desc
                break
    return devices


def list_sources() -> list[PulseDevice]:
    return _list("sources")


def list_sinks() -> list[PulseDevice]:
    return _list("sinks")


def find_by_keywords(
    devices: Iterable[PulseDevice], keywords: tuple[str, ...]
) -> PulseDevice | None:
    for d in devices:
        haystack = f"{d.name} {d.description}".lower()
        if any(k in haystack for k in keywords):
            return d
    return None


def pprint_devices(title: str, devices: Iterable[PulseDevice]) -> None:
    print(f"\n{title}")
    print("-" * len(title))
    if not devices:
        print("  (none)")
        return
    for d in devices:
        mute = "muted" if d.mute else "ok"
        vol = f"{d.volume_pct}%" if d.volume_pct is not None else "?"
        print(f"  [{d.index:>2}] {d.name}")
        if d.description:
            print(f"       desc: {d.description}")
        print(f"       state={d.state}  {mute}  vol={vol}")


def set_mute(index: int, mute: bool) -> None:
    _run(["pactl", "set-source-mute", str(index), "1" if mute else "0"])


def set_volume(index: int, pct: int) -> None:
    _run(["pactl", "set-source-volume", str(index), f"{pct}%"])


def set_default_source(name: str) -> None:
    _run(["pactl", "set-default-source", name])


def auto_hollyland() -> int:
    """Follow the voice_note.txt recipe to bring the Hollyland mic online."""
    sources = list_sources()
    mic = find_by_keywords(sources, HOLLYLAND_KEYWORDS)
    if mic is None:
        print("Hollyland mic not found. Plug it in and retry.")
        pprint_devices("Current sources", sources)
        return 1
    print(f"Found: [{mic.index}] {mic.name}")
    set_default_source(mic.name)
    set_mute(mic.index, False)
    set_volume(mic.index, 100)
    print(f"Set as default source, unmuted, volume 100%.")
    return quick_test_capture(mic.index, seconds=2)


def quick_test_capture(index: int, seconds: float = 2.0) -> int:
    """Record a few seconds with parec and report sample statistics."""
    import numpy as np

    with tempfile.NamedTemporaryFile(suffix=".pcm", delete=False) as tmp:
        tmp_path = Path(tmp.name)
    cmd = [
        "parec", "-d", str(index),
        "--format=s16le", "--rate=16000", "--channels=1", "--raw",
    ]
    print(f"Capturing {seconds:.1f}s from source {index} ...")
    proc = subprocess.Popen(cmd, stdout=open(tmp_path, "wb"))
    try:
        proc.wait(timeout=seconds + 2)
    except subprocess.TimeoutExpired:
        proc.terminate()
    finally:
        proc.poll()
    try:
        data = np.fromfile(tmp_path, dtype=np.int16)
    finally:
        tmp_path.unlink(missing_ok=True)

    if data.size == 0:
        print("  No samples captured. Check device index and that parec is installed.")
        return 2
    rms = float((data.astype("float32") ** 2).mean()) ** 0.5
    peak = int(abs(data).max())
    print(f"  samples={data.size}  peak={peak}  rms={rms:.0f}")
    if rms > 50:
        print("  MIC WORKS.")
        return 0
    print("  Signal looks silent — check transmitter is on + mic not muted + volume up.")
    return 3


def main() -> int:
    p = argparse.ArgumentParser(description="G1 audio device helper (pactl wrapper)")
    sub = p.add_subparsers(dest="cmd", required=True)
    sub.add_parser("list", help="List sources and sinks")
    f = sub.add_parser("find", help="Find a device by substring")
    f.add_argument("keyword")
    sub.add_parser("auto-hollyland", help="Auto-detect Hollyland mic and set up")
    q = sub.add_parser("quick-test", help="Record 2s from a source and report")
    q.add_argument("index", type=int)
    q.add_argument("--seconds", type=float, default=2.0)
    m = sub.add_parser("mute", help="Mute a source")
    m.add_argument("index", type=int)
    u = sub.add_parser("unmute", help="Unmute a source")
    u.add_argument("index", type=int)
    v = sub.add_parser("volume", help="Set source volume percent")
    v.add_argument("index", type=int)
    v.add_argument("percent", type=int)
    args = p.parse_args()

    if args.cmd == "list":
        pprint_devices("SOURCES (mics)", list_sources())
        pprint_devices("SINKS (speakers)", list_sinks())
        return 0
    if args.cmd == "find":
        kw = args.keyword.lower()
        src = find_by_keywords(list_sources(), (kw,))
        snk = find_by_keywords(list_sinks(), (kw,))
        if src:
            print(f"source [{src.index}] {src.name}")
        if snk:
            print(f"sink   [{snk.index}] {snk.name}")
        if not src and not snk:
            print(f"No device matches '{args.keyword}'")
            return 1
        return 0
    if args.cmd == "auto-hollyland":
        return auto_hollyland()
    if args.cmd == "quick-test":
        return quick_test_capture(args.index, args.seconds)
    if args.cmd == "mute":
        set_mute(args.index, True)
        return 0
    if args.cmd == "unmute":
        set_mute(args.index, False)
        return 0
    if args.cmd == "volume":
        set_volume(args.index, args.percent)
        return 0
    return 1


if __name__ == "__main__":
    sys.exit(main())
