"""Audio control endpoints — mic mute, speaker mute, device profile selection."""

from __future__ import annotations

import asyncio
import os
import subprocess
import threading

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from Project.Sanad.core.logger import get_logger
from Project.Sanad.voice import audio_devices as ad

log = get_logger("audio_route")

router = APIRouter()

# ─────────────────────── G1 built-in speaker (DDS) ───────────────────────
#
# pactl set-sink-mute has NO effect on the G1 built-in speaker because
# sanad_voice.py streams PCM to it via the Unitree DDS AudioClient API,
# bypassing PulseAudio entirely. To actually silence the built-in speaker
# mid-playback we must call AudioClient.SetVolume(0) over DDS.
#
# This module keeps a lazily-initialized AudioClient + a cached volume so
# the dashboard can mute/unmute without waiting on DDS init for every click.

_g1_audio_client = None
_g1_audio_lock = threading.Lock()
_g1_current_volume: int = 100  # what's actually on the hardware right now
_g1_user_volume: int = 100     # the user's preferred "unmuted" level
_g1_init_error: str = ""


def _load_persisted_g1_volume() -> int:
    """Read the saved G1 volume from data/motions/config.json.

    Keys are `audio.g1_volume` (persistent target level 0-100). Returns
    100 if no value is stored — matches the default the Unitree SDK sets
    on the voice service.
    """
    try:
        from Project.Sanad.config import load_config
        cfg = load_config() or {}
        audio = cfg.get("audio") or {}
        vol = int(audio.get("g1_volume", 100))
        return max(0, min(100, vol))
    except Exception:
        return 100


def _save_persisted_g1_volume(level: int) -> None:
    """Persist the user's volume choice to config.json so it survives restart."""
    try:
        from Project.Sanad.config import load_config, save_config
        cfg = load_config() or {}
        audio = cfg.get("audio") if isinstance(cfg.get("audio"), dict) else {}
        audio["g1_volume"] = max(0, min(100, int(level)))
        cfg["audio"] = audio
        save_config(cfg)
    except Exception as exc:
        log.warning("could not persist g1_volume: %s", exc)


# Initialize user volume from the persisted value so the dashboard shows
# the correct level on first load even if no one has touched it yet.
_g1_user_volume = _load_persisted_g1_volume()
_g1_current_volume = _g1_user_volume


def _get_g1_audio_client():
    """Lazy-init AudioClient. Safe to call from multiple routes."""
    global _g1_audio_client, _g1_init_error
    if _g1_audio_client is not None:
        return _g1_audio_client
    try:
        from unitree_sdk2py.core.channel import ChannelFactoryInitialize
        from unitree_sdk2py.g1.audio.g1_audio_client import AudioClient
    except ImportError as exc:
        _g1_init_error = f"unitree_sdk2py not installed: {exc}"
        return None
    iface = os.environ.get("SANAD_DDS_INTERFACE", "eth0")
    # ChannelFactoryInitialize can only be called once per process. The
    # arm controller normally calls it first at startup — the second call
    # either no-ops or raises, so wrap it defensively.
    try:
        ChannelFactoryInitialize(0, iface)
    except Exception as exc:
        log.debug("ChannelFactoryInitialize already called or failed: %s", exc)
    try:
        client = AudioClient()
        client.SetTimeout(5.0)
        client.Init()
        _g1_audio_client = client
        log.info("G1 AudioClient initialized for dashboard mute control (iface=%s)", iface)
        return client
    except Exception as exc:
        _g1_init_error = f"AudioClient init failed: {exc}"
        log.warning("G1 AudioClient init failed: %s", exc)
        return None


def _pactl(args: list[str]) -> subprocess.CompletedProcess[str]:
    return subprocess.run(["pactl", *args], check=True, text=True, capture_output=True)


def _get_muted(kind: str, name: str) -> bool:
    if not name:
        return False
    try:
        cmd = "get-source-mute" if kind == "source" else "get-sink-mute"
        r = _pactl([cmd, name])
        return (r.stdout or "").strip().lower().endswith("yes")
    except (FileNotFoundError, subprocess.CalledProcessError):
        return False


def _set_muted(kind: str, name: str, muted: bool) -> bool:
    if not name:
        return False
    cmd = "set-source-mute" if kind == "source" else "set-sink-mute"
    _pactl([cmd, name, "1" if muted else "0"])
    return _get_muted(kind, name)


def _current_sink_source() -> tuple[str, str]:
    cur = ad.current_selection()
    return cur.get("sink", ""), cur.get("source", "")


# ─────────────────────── status / mute ───────────────────────


@router.get("/status")
async def audio_status():
    """Return current device + mute state + G1 speaker volume.

    `speaker_muted` is the EFFECTIVE mute state — True if either the
    PulseAudio sink is muted OR the G1 built-in speaker volume is 0.
    `pulse_sink_muted` and `g1_speaker_muted` are the per-path states.
    `g1_current_volume` = what's live on the hardware.
    `g1_user_volume`    = the user's preferred unmuted level (what we
                          restore to when they un-mute).
    """
    def _do():
        sink, source = _current_sink_source()
        cur = ad.current_selection()
        pulse_muted = _get_muted("sink", sink)
        # Read cached state — avoid DDS GetVolume round-trips on every poll
        global _g1_current_volume, _g1_user_volume
        g1_muted = _g1_current_volume == 0
        return {
            "mic_muted": _get_muted("source", source),
            # Effective (OR of both paths) — the badge the user sees
            "speaker_muted": pulse_muted or g1_muted,
            # Per-path breakdown so the UI can distinguish
            "pulse_sink_muted": pulse_muted,
            "g1_speaker_muted": g1_muted,
            "g1_current_volume": _g1_current_volume,
            "g1_user_volume": _g1_user_volume,
            # Only report available once an AudioClient has actually been
            # built — reporting True before any init attempt made the UI
            # advertise G1 speaker controls that then 503 on first use.
            # `g1_init_error` surfaces *why* it's unavailable (or "" if
            # init was never attempted yet).
            "g1_available": _g1_audio_client is not None,
            "g1_init_error": _g1_init_error,
            "sink": sink,
            "source": source,
            "current": cur,
            "pactl_available": ad.pactl_available(),
        }
    return await asyncio.to_thread(_do)


@router.post("/mic/mute")
async def toggle_mic(muted: bool | None = None):
    def _do():
        _, source = _current_sink_source()
        if not source:
            raise HTTPException(503, "No source device selected")
        target = muted if muted is not None else not _get_muted("source", source)
        try:
            actual = _set_muted("source", source, target)
        except (FileNotFoundError, subprocess.CalledProcessError) as exc:
            raise HTTPException(500, f"pactl failed: {exc}")
        return {"mic_muted": actual, "source": source}
    return await asyncio.to_thread(_do)


@router.post("/speaker/mute")
async def toggle_speaker(muted: bool | None = None):
    """Mute/unmute the SPEAKER — both the PulseAudio sink AND the G1
    built-in speaker, so the effect is audible regardless of which
    playback path is currently active (Anker PowerConf via PyAudio vs
    G1 built-in via Unitree DDS AudioClient).

    Each of the two paths is attempted independently; the response
    reports which one(s) succeeded. If either path is muted, the button
    shows as "Muted".
    """
    def _do():
        global _g1_current_volume, _g1_user_volume
        sink, _ = _current_sink_source()

        # Decide target state — if muted is None, toggle based on
        # whichever path is currently "not muted"
        if muted is None:
            pulse_cur = _get_muted("sink", sink) if sink else False
            g1_cur = _g1_current_volume == 0
            # Toggle: if anything is live, mute everything; else unmute all
            target = not (pulse_cur or g1_cur)
        else:
            target = bool(muted)

        result = {"speaker_muted": target, "pulse": None, "g1": None}

        # ── Path 1: PulseAudio sink (Anker PowerConf, USB, etc.) ──
        if sink:
            try:
                actual_pulse = _set_muted("sink", sink, target)
                result["pulse"] = {"ok": True, "muted": actual_pulse, "sink": sink}
            except (FileNotFoundError, subprocess.CalledProcessError) as exc:
                result["pulse"] = {"ok": False, "error": f"pactl failed: {exc}"}
        else:
            result["pulse"] = {"ok": False, "error": "no sink selected"}

        # ── Path 2: G1 built-in speaker via DDS AudioClient ──
        # Mute = SetVolume(0). Unmute = SetVolume(_g1_user_volume) so the
        # user's chosen level is restored (instead of always jumping back
        # to 100).
        client = _get_g1_audio_client()
        if client is None:
            result["g1"] = {"ok": False, "error": _g1_init_error or "AudioClient unavailable"}
        else:
            volume = 0 if target else _g1_user_volume
            try:
                with _g1_audio_lock:
                    code = client.SetVolume(volume)
                    _g1_current_volume = volume
                result["g1"] = {
                    "ok": True, "muted": volume == 0,
                    "volume": volume, "code": code,
                }
                log.info("G1 speaker volume set to %d (rc=%s)", volume, code)
            except Exception as exc:
                result["g1"] = {"ok": False, "error": f"SetVolume failed: {exc}"}

        # Final effective state — either path counts as muted
        pulse_muted = result["pulse"].get("muted", False) if result["pulse"] else False
        g1_muted = result["g1"].get("muted", False) if result["g1"] else False
        result["speaker_muted"] = bool(pulse_muted or g1_muted) if target else False
        result["sink"] = sink
        result["g1_current_volume"] = _g1_current_volume
        result["g1_user_volume"] = _g1_user_volume
        return result
    return await asyncio.to_thread(_do)


@router.post("/g1-speaker/mute")
async def toggle_g1_speaker_only(muted: bool | None = None):
    """Mute/unmute ONLY the G1 built-in speaker via DDS AudioClient.

    Useful for testing the DDS path in isolation — the normal
    /speaker/mute endpoint hits both PulseAudio and G1 at once.
    On unmute, restores the user's last chosen volume (not always 100).
    """
    def _do():
        global _g1_current_volume
        client = _get_g1_audio_client()
        if client is None:
            raise HTTPException(
                503,
                f"G1 AudioClient unavailable: {_g1_init_error or 'unknown'}",
            )
        if muted is None:
            target = _g1_current_volume > 0  # toggle
        else:
            target = bool(muted)
        volume = 0 if target else _g1_user_volume
        try:
            with _g1_audio_lock:
                code = client.SetVolume(volume)
                _g1_current_volume = volume
        except Exception as exc:
            raise HTTPException(500, f"SetVolume failed: {exc}")
        log.info("G1 speaker volume set to %d (rc=%s)", volume, code)
        return {
            "g1_muted": volume == 0,
            "volume": volume,
            "user_volume": _g1_user_volume,
            "return_code": code,
        }
    return await asyncio.to_thread(_do)


# ─────────────────────── G1 speaker volume (0-100) ───────────────────────


class G1VolumePayload(BaseModel):
    level: int  # 0..100


@router.get("/g1-speaker/volume")
async def get_g1_volume():
    """Return the current G1 speaker volume state.

    Response:
      {
        "available":      true,          # AudioClient available?
        "current_volume": 75,            # what's on hardware right now
        "user_volume":    75,            # user's preferred unmuted level
        "muted":          false,         # current_volume == 0
        "persisted":      75,            # value from config.json
      }
    """
    def _do():
        return {
            # True only after an AudioClient was actually constructed —
            # `init_error` (below) explains an unavailable/never-tried state.
            "available": _g1_audio_client is not None,
            "current_volume": _g1_current_volume,
            "user_volume": _g1_user_volume,
            "muted": _g1_current_volume == 0,
            "persisted": _load_persisted_g1_volume(),
            "init_error": _g1_init_error,
        }
    return await asyncio.to_thread(_do)


@router.post("/g1-speaker/volume")
async def set_g1_volume(payload: G1VolumePayload):
    """Set the G1 built-in speaker volume via DDS AudioClient.

    Body: `{"level": 0..100}`

    Effects:
      - Immediately applies to hardware via AudioClient.SetVolume(level).
      - Persists to data/motions/config.json under `audio.g1_volume` so
        it survives restarts.
      - If level > 0, updates _g1_user_volume (the "unmuted" restore
        target). level == 0 is a soft mute that preserves user_volume.
      - Takes effect on the live playback immediately — you can slide
        the volume down mid-speech and hear it get quieter.
    """
    def _do():
        global _g1_current_volume, _g1_user_volume
        level = int(payload.level)
        if not 0 <= level <= 100:
            raise HTTPException(400, "level must be 0..100")

        # 1) G1 chest speaker (DDS) — best-effort so it works even when an
        #    external sink (JBL) is the active output.
        code = None
        client = _get_g1_audio_client()
        if client is not None:
            try:
                with _g1_audio_lock:
                    code = client.SetVolume(level)
                    _g1_current_volume = level
            except Exception as exc:
                log.warning("G1 SetVolume failed: %s", exc)
        if level > 0:
            _g1_user_volume = level

        # 2) The ACTIVE profile's PulseAudio sink (JBL / Anker / …). Target the
        #    RESOLVED sink from the saved selection, NOT @DEFAULT_SINK@ — the PA
        #    default can be a different sink (e.g. the chest platform-sound) even
        #    when the JBL is the selected output, so @DEFAULT_SINK@ would move the
        #    wrong sink and the slider would appear to do nothing on the JBL.
        pa_applied = False
        try:
            sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@"
            _pactl(["set-sink-volume", sink, "%d%%" % level])
            if level > 0:
                _pactl(["set-sink-mute", sink, "0"])
            pa_applied = True
        except Exception as exc:
            log.warning("PA set-sink-volume failed: %s", exc)

        if client is None and not pa_applied:
            raise HTTPException(503, "No speaker available (G1 + PulseAudio both failed)")

        _save_persisted_g1_volume(_g1_user_volume)
        log.info("volume → %d (g1_rc=%s, pa=%s, user_pref=%d)",
                 level, code, pa_applied, _g1_user_volume)
        return {
            "ok": True,
            "current_volume": level,
            "user_volume": _g1_user_volume,
            "muted": level == 0,
            "return_code": code,
            "pa_applied": pa_applied,
            "persisted": True,
        }
    return await asyncio.to_thread(_do)


# ─────────────────────── device profiles ───────────────────────


@router.get("/devices")
async def list_devices():
    """Full device + profile listing for the dashboard picker."""
    return await asyncio.to_thread(ad.status)


@router.get("/profiles")
async def list_profiles():
    """Just the named profiles + which are currently plugged in."""
    def _do():
        from dataclasses import asdict
        detected = ad.detect_plugged_profiles() if ad.pactl_available() else []
        detected_ids = {d["profile"]["id"] for d in detected}
        return {
            "profiles": [
                {
                    **asdict(p),
                    "available": p.id in detected_ids,
                }
                for p in ad.PROFILES
            ],
            "detected_ids": list(detected_ids),
        }
    return await asyncio.to_thread(_do)


class ProfileSelect(BaseModel):
    profile_id: str


@router.post("/select-profile")
async def select_profile(payload: ProfileSelect):
    def _do():
        result = ad.select_profile(payload.profile_id)
        if not result.get("ok"):
            raise HTTPException(409, result.get("error") or "Could not select profile")
        # Best-effort: tell the audio_manager to refresh its cached state
        try:
            from Project.Sanad.main import audio_mgr
            if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"):
                audio_mgr.refresh_devices()
        except Exception:
            pass
        return result
    return await asyncio.to_thread(_do)


class ManualSelect(BaseModel):
    sink: str
    source: str


@router.post("/select-manual")
async def select_manual(payload: ManualSelect):
    def _do():
        if not payload.sink and not payload.source:
            raise HTTPException(400, "At least one of sink/source required")
        result = ad.select_manual(payload.sink, payload.source)
        if not result.get("ok"):
            raise HTTPException(500, str(result.get("errors") or "Selection failed"))
        try:
            from Project.Sanad.main import audio_mgr
            if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"):
                audio_mgr.refresh_devices()
        except Exception:
            pass
        return result
    return await asyncio.to_thread(_do)


@router.post("/refresh")
async def refresh_devices():
    """Re-scan plugged devices and re-resolve current selection."""
    return await asyncio.to_thread(ad.status)


@router.post("/apply")
async def apply_audio():
    """Re-scan all USB ports, resolve the best profile, and set pactl defaults.

    Use this after plugging/unplugging devices or switching USB ports.
    """
    def _do():
        result = ad.apply_current_selection()
        # Also refresh AudioManager so it picks up the new sink/source
        try:
            from Project.Sanad.main import audio_mgr
            if audio_mgr is not None:
                audio_mgr.refresh_devices()
        except Exception:
            pass
        # Hot-swap the live Gemini voice to the selected profile too, so picking
        # a device (e.g. the JBL) moves BOTH record playback AND the live voice
        # to it — without dropping the session. Best-effort; no-op if not running.
        try:
            from Project.Sanad.main import live_sub
            pid = (ad.load_state() or {}).get("profile_id")
            if (pid and live_sub is not None and hasattr(live_sub, "send_profile")
                    and hasattr(live_sub, "is_running") and live_sub.is_running()):
                live_sub.send_profile(pid, reason="dashboard audio Apply")
        except Exception:
            pass
        # Restore the user's SAVED volume to the selected sink (USB/BT speakers
        # like the JBL otherwise come back at a low PulseAudio default). Use the
        # saved level, NOT a forced 100%, so the slider/sink keep the user's
        # choice across selects + restarts. Target the resolved sink.
        try:
            sink = (ad.load_state() or {}).get("sink") or "@DEFAULT_SINK@"
            _pactl(["set-sink-volume", sink, "%d%%" % _g1_user_volume])
            if _g1_user_volume > 0:
                _pactl(["set-sink-mute", sink, "0"])
        except Exception:
            pass
        return result
    return await asyncio.to_thread(_do)


# ─────────────────────── Reset endpoints (Pulse + USB) ───────────────────────
#
# Two distinct recovery paths for the dashboard's audio panel:
#
#   POST /api/audio/reset       — SOFT: restart pulseaudio / pipewire-pulse.
#       Fixes Pulse-side state (stuck profile, lost default sink, crashed
#       module). Cannot recover a kernel-side missing USB capture descriptor
#       — snd-usb-audio parses those at probe time and Pulse can't influence
#       that. Use for "devices look weird" failures.
#
#   POST /api/audio/usb-reset   — HARD: unbind+rebind snd-usb-audio scoped
#       to the Anker VID:PID. Forces snd-usb-audio to re-parse UAC1
#       descriptors → input profile reappears even after the firmware/USB
#       handshake dropped it. Use for "Anker mic missing from pactl" — the
#       symptom soft-reset cannot fix.
#
# Both gate with module-level locks (no concurrent reset), refuse while Live
# Gemini is running or a record is mid-playback, and return structured
# before/after diagnostics so the dashboard can show meaningful toasts.

_RESET_LOCK = threading.Lock()
_USB_RESET_LOCK = threading.Lock()

# Anker PowerConf A3321 — used both for VID:PID matching in sysfs and for
# logging. Change here if you add support for a different USB conference
# device (Hollyland etc).
_USB_RESET_TARGETS = (
    {"vid": "291a", "pid": "3301", "label": "Anker PowerConf"},
)


def _refuse_if_busy() -> None:
    """Raise HTTPException(409) if Live Gemini is active or a record is playing.

    Used by both reset endpoints — a userspace audio restart mid-stream
    leaves the active session in a broken state (PortAudio handle pointing
    at a dead Pulse, in-flight write() raises, etc.). Cheaper to refuse
    than to recover.
    """
    try:
        from Project.Sanad.main import live_sub
    except Exception:
        live_sub = None
    if live_sub is not None:
        try:
            st = live_sub.status() or {}
        except Exception:
            st = {}
        state = (st.get("state") or "").lower()
        if st.get("running") or state not in ("", "stopped", "error"):
            raise HTTPException(
                409, f"Stop Live Gemini before resetting audio (state={state or '?'}).",
            )

    try:
        from Project.Sanad.main import audio_mgr
    except Exception:
        audio_mgr = None
    if audio_mgr is not None and hasattr(audio_mgr, "playback_status"):
        try:
            ps = audio_mgr.playback_status() or {}
            if ps.get("playing"):
                raise HTTPException(
                    409, "Stop the active playback before resetting audio.",
                )
        except HTTPException:
            raise
        except Exception:
            pass


def _detect_pa_flavour() -> str:
    """Return 'pipewire' if pipewire-pulse is the active daemon, else 'pulse'."""
    try:
        r = subprocess.run(
            ["pgrep", "-x", "pipewire-pulse"],
            check=False, capture_output=True, text=True, timeout=1.0,
        )
        if r.returncode == 0 and (r.stdout or "").strip():
            return "pipewire"
    except (FileNotFoundError, subprocess.SubprocessError):
        pass
    return "pulse"


def _kill_audio_daemon(flavour: str) -> dict:
    """Issue the restart command for the detected daemon. Non-zero exit is a
    soft warning (some installs return 1 when there's no daemon to kill)."""
    if flavour == "pipewire":
        cmd = ["systemctl", "--user", "restart", "pipewire-pulse.service"]
    else:
        cmd = ["pulseaudio", "-k"]
    try:
        r = subprocess.run(cmd, check=False, capture_output=True,
                           text=True, timeout=5.0)
        info = {"cmd": " ".join(cmd), "returncode": r.returncode,
                "stderr": (r.stderr or "").strip()[:300]}
        if r.returncode != 0:
            log.warning("audio reset: %s exited %d (%s)",
                        cmd[0], r.returncode, info["stderr"])
        return info
    except FileNotFoundError as exc:
        return {"cmd": " ".join(cmd), "returncode": -1,
                "stderr": f"binary missing: {exc}"}
    except subprocess.TimeoutExpired:
        return {"cmd": " ".join(cmd), "returncode": -1,
                "stderr": "timeout (>5s)"}


def _remote_pulse_mode() -> bool:
    """True when this process talks to ANOTHER machine-scope PulseAudio —
    the containerized deployments (P1–P4) run as root with PULSE_SERVER
    pointed at the host's socket. Restarting that daemon is not ours to do."""
    return bool(os.environ.get("PULSE_SERVER")) or os.path.exists("/.dockerenv")


def _cycle_remote_pulse() -> dict:
    """Suspend→resume every sink/source on the remote (host) PulseAudio.
    A suspend cycle forces the ALSA device rebind and clears wedged
    streams — the useful part of a daemon restart, minus the restart."""
    cycled: list[str] = []
    for kind, flag in (("sinks", "suspend-sink"), ("sources", "suspend-source")):
        try:
            r = subprocess.run(["pactl", "list", "short", kind], check=False,
                               capture_output=True, text=True, timeout=5.0)
            names = [ln.split("\t")[1] for ln in (r.stdout or "").splitlines()
                     if "\t" in ln]
        except (FileNotFoundError, subprocess.SubprocessError):
            names = []
        for name in names:
            if name.endswith(".monitor"):
                continue
            for state in ("1", "0"):
                subprocess.run(["pactl", flag, name, state], check=False,
                               capture_output=True, text=True, timeout=5.0)
            cycled.append(name)
    return {"cmd": "remote pulse suspend/resume cycle", "returncode": 0,
            "stderr": "", "cycled": cycled}


def _wait_for_pactl(deadline_s: float = 5.0, interval_s: float = 0.2) -> bool:
    """Poll `pactl info` until it returns 0 or the deadline expires."""
    import time as _time
    end = _time.monotonic() + deadline_s
    while _time.monotonic() < end:
        if ad.pactl_available():
            return True
        _time.sleep(interval_s)
    return False


@router.post("/reset")
async def reset_audio_subsystem():
    """SOFT reset — restart pulseaudio/pipewire-pulse and re-resolve devices.

    Use when devices look stuck, pactl is unavailable, or the wrong sink
    is being selected. **Does NOT recover a kernel-side missing USB capture
    descriptor** — for that symptom use /api/audio/usb-reset.
    """
    remote_pulse = _remote_pulse_mode()
    if os.geteuid() == 0 and not remote_pulse:
        raise HTTPException(
            403, "Refusing to reset audio as root — Sanad must run as the "
            "unitree user so the per-user PulseAudio session is reachable.",
        )
    if not _RESET_LOCK.acquire(blocking=False):
        raise HTTPException(429, "Reset already in progress.")
    try:
        _refuse_if_busy()
        log.info(
            "audio reset requested (uid=%s PULSE_RUNTIME_PATH=%s XDG_RUNTIME_DIR=%s)",
            os.geteuid(),
            os.environ.get("PULSE_RUNTIME_PATH") or "-",
            os.environ.get("XDG_RUNTIME_DIR") or "-",
        )
        try:
            from Project.Sanad.main import audio_mgr
        except Exception:
            audio_mgr = None

        def _do() -> dict:
            before = {"pactl_available": ad.pactl_available(),
                      "selection": ad.current_selection()}

            # Quiesce AudioManager so the next play_wav rebinds cleanly.
            pya_closed = False
            if audio_mgr is not None:
                play_lock = getattr(audio_mgr, "play_lock", None)
                acquired = False
                if play_lock is not None:
                    acquired = play_lock.acquire(timeout=2.0)
                try:
                    try:
                        audio_mgr.close()
                        pya_closed = True
                    except Exception as exc:
                        log.warning("audio reset: audio_mgr.close failed: %s", exc)
                finally:
                    if acquired and play_lock is not None:
                        play_lock.release()

            if remote_pulse:
                # Containerized: the daemon is the HOST's — never kill it.
                # Cycle its devices over the socket instead.
                flavour = "pulse-remote"
                kill_info = _cycle_remote_pulse()
                came_back = _wait_for_pactl(deadline_s=5.0)
            else:
                flavour = _detect_pa_flavour()
                kill_info = _kill_audio_daemon(flavour)
                came_back = _wait_for_pactl(deadline_s=5.0)
            if not came_back and flavour == "pulse":
                # autospawn may be disabled — try an explicit start.
                try:
                    subprocess.run(["pulseaudio", "--start"], check=False,
                                   capture_output=True, text=True, timeout=3.0)
                except (FileNotFoundError, subprocess.SubprocessError) as exc:
                    log.warning("audio reset: pulseaudio --start failed: %s", exc)
                came_back = _wait_for_pactl(deadline_s=2.0)
            if not came_back:
                raise HTTPException(500, {
                    "error": "audio daemon did not return within ~7s",
                    "flavour": flavour, "kill": kill_info,
                })

            apply_result: dict = {}
            try:
                apply_result = ad.apply_current_selection() or {}
            except Exception as exc:
                log.warning("audio reset: apply_current_selection failed: %s", exc)
                apply_result = {"error": str(exc)}

            if audio_mgr is not None:
                try:
                    import pyaudio
                    audio_mgr.pya = pyaudio.PyAudio()
                    audio_mgr.refresh_devices()
                except Exception as exc:
                    log.error("audio reset: PyAudio re-init failed: %s", exc)
                    raise HTTPException(
                        500, f"PortAudio re-init failed after daemon restart: {exc}")

            after_sel = ad.current_selection() or {}
            detected = ad.detect_plugged_profiles() or []
            after = {
                "pactl_available": ad.pactl_available(),
                "selection": after_sel,
                "detected_profiles": [p.get("profile", {}).get("id") for p in detected],
            }
            return {
                "ok": True, "best_effort": True, "flavour": flavour,
                "kill": kill_info, "pya_reinitialized": pya_closed,
                "apply_result": apply_result,
                "input_recovered": bool(after_sel.get("source")),
                "output_recovered": bool(after_sel.get("sink")),
                "before": before, "after": after,
                "hint": ("Soft reset only fixes Pulse-side state. If "
                         "input_recovered is False, try POST /api/audio/usb-reset "
                         "or physically replug the dongle."),
            }
        return await asyncio.to_thread(_do)
    finally:
        _RESET_LOCK.release()


def _find_usb_devices_by_vid_pid(vid: str, pid: str) -> list[str]:
    """Return sysfs bus-id strings (e.g. '1-3') for every USB device whose
    idVendor/idProduct match. Empty list when nothing matches.

    We read /sys/bus/usb/devices/* — every USB *device* (not interface) has
    idVendor/idProduct files. Interfaces (paths with a colon, e.g. '1-3:1.1')
    do not, so they're naturally skipped.
    """
    import glob
    hits: list[str] = []
    for path in glob.glob("/sys/bus/usb/devices/*"):
        name = os.path.basename(path)
        if ":" in name:
            continue
        try:
            with open(os.path.join(path, "idVendor")) as f:
                v = f.read().strip().lower()
            with open(os.path.join(path, "idProduct")) as f:
                p = f.read().strip().lower()
        except OSError:
            continue
        if v == vid.lower() and p == pid.lower():
            hits.append(name)
    return hits


def _snd_usb_interfaces_for_device(bus_id: str) -> list[str]:
    """For USB device `bus_id` (e.g. '1-3'), return all interface names that
    are currently bound to the snd-usb-audio driver (e.g. ['1-3:1.0']).

    Used so we unbind ONLY the audio interfaces and don't touch HID / HUB
    interfaces on the same composite device.
    """
    import glob
    bound: list[str] = []
    base = f"/sys/bus/usb/devices/{bus_id}"
    for iface in glob.glob(f"{base}/{bus_id}:*"):
        driver_link = os.path.join(iface, "driver")
        if not os.path.islink(driver_link):
            continue
        try:
            driver = os.path.basename(os.readlink(driver_link))
        except OSError:
            continue
        if driver == "snd-usb-audio":
            bound.append(os.path.basename(iface))
    return bound


def _write_sysfs(path: str, value: str) -> tuple[bool, str]:
    """Write `value` to a sysfs file. Returns (success, error_message).

    Writes to /sys/bus/usb/drivers/snd-usb-audio/{bind,unbind} usually
    require root. If permission denied, the caller should fall back to
    invoking shell_scripts/reset_anker_usb.sh via sudo (one-time sudoers
    setup documented in that script's header).
    """
    try:
        with open(path, "w") as f:
            f.write(value)
        return True, ""
    except PermissionError as exc:
        return False, f"permission denied: {path} ({exc})"
    except OSError as exc:
        return False, f"write failed: {path} ({exc})"


@router.post("/usb-reset")
async def usb_reset_anker():
    """HARD reset — unbind+rebind snd-usb-audio for the Anker (VID:PID
    291a:3301). Forces the kernel to re-parse the USB Audio Class
    descriptors, which is the only way to recover a missing capture profile
    on this Jetson without a physical replug.

    Tries two paths:
      1. Direct sysfs write (no sudo) — works if a udev rule has set
         `audio` group ownership / world-write on the snd-usb-audio bind
         files, or if Sanad runs as root (it shouldn't).
      2. Fallback to `sudo shell_scripts/reset_anker_usb.sh` — works after
         a one-time sudoers entry; see that script's header for setup.

    Refuses while Live Gemini or a record playback is in flight (same
    guard as the soft reset).
    """
    if not _USB_RESET_LOCK.acquire(blocking=False):
        raise HTTPException(429, "USB reset already in progress.")
    try:
        _refuse_if_busy()

        # Find candidate Anker USB devices currently enumerated.
        candidates: list[dict] = []
        for tgt in _USB_RESET_TARGETS:
            for bus_id in _find_usb_devices_by_vid_pid(tgt["vid"], tgt["pid"]):
                candidates.append({"bus_id": bus_id, **tgt})
        if not candidates:
            wanted = ", ".join(
                "{}:{}".format(t["vid"], t["pid"]) for t in _USB_RESET_TARGETS
            )
            raise HTTPException(
                404,
                f"No matching USB device found (looked for {wanted}). "
                "Plug the Anker dongle and try again.",
            )

        log.info("usb reset: candidates=%s", candidates)

        def _do() -> dict:
            before_detected = [
                p.get("profile", {}).get("id")
                for p in (ad.detect_plugged_profiles() or [])
            ]
            results: list[dict] = []
            for cand in candidates:
                bus = cand["bus_id"]
                ifaces = _snd_usb_interfaces_for_device(bus)
                attempt = {"bus_id": bus, "label": cand["label"],
                           "snd_interfaces": ifaces, "method": None,
                           "ok": False, "error": ""}
                if not ifaces:
                    attempt["error"] = ("no snd-usb-audio interfaces bound "
                                        "to this device — already unbound or "
                                        "kernel didn't claim it")
                    results.append(attempt)
                    continue

                # ─── Path 1: direct sysfs write ───
                unbind_path = "/sys/bus/usb/drivers/snd-usb-audio/unbind"
                bind_path = "/sys/bus/usb/drivers/snd-usb-audio/bind"
                direct_ok = True
                direct_err = ""
                for iface in ifaces:
                    ok, err = _write_sysfs(unbind_path, iface)
                    if not ok:
                        direct_ok = False
                        direct_err = err
                        break
                if direct_ok:
                    import time as _time
                    _time.sleep(0.5)
                    for iface in ifaces:
                        ok, err = _write_sysfs(bind_path, iface)
                        if not ok:
                            direct_ok = False
                            direct_err = err
                            break
                if direct_ok:
                    attempt.update({"method": "direct-sysfs", "ok": True})
                    results.append(attempt)
                    continue

                # ─── Path 2: sudo helper script ───
                from pathlib import Path as _Path
                helper = (_Path(__file__).resolve().parent.parent.parent
                          / "shell_scripts" / "reset_anker_usb.sh")
                if not helper.exists():
                    attempt.update({"method": "direct-sysfs",
                                    "error": f"{direct_err}; helper not present "
                                             f"at {helper}"})
                    results.append(attempt)
                    continue
                try:
                    r = subprocess.run(
                        ["sudo", "-n", str(helper), bus],
                        check=False, capture_output=True, text=True, timeout=10.0,
                    )
                    attempt["method"] = "sudo-helper"
                    if r.returncode == 0:
                        attempt["ok"] = True
                    else:
                        attempt["error"] = (
                            f"sudo helper exited {r.returncode}: "
                            f"{(r.stderr or r.stdout or '').strip()[:300]}"
                        )
                except subprocess.TimeoutExpired:
                    attempt["error"] = "sudo helper timed out (>10s)"
                except FileNotFoundError as exc:
                    attempt["error"] = f"sudo not available: {exc}"
                results.append(attempt)

            # Settle, then re-detect
            import time as _time
            _time.sleep(1.0)
            try:
                ad.apply_current_selection()
            except Exception:
                pass
            try:
                from Project.Sanad.main import audio_mgr
                if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"):
                    audio_mgr.refresh_devices()
            except Exception:
                pass

            after_detected = [
                p.get("profile", {}).get("id")
                for p in (ad.detect_plugged_profiles() or [])
            ]
            any_ok = any(r.get("ok") for r in results)
            mic_now = any(
                "anker" in (p.get("profile", {}).get("id") or "").lower()
                for p in (ad.detect_plugged_profiles() or [])
            )

            return {
                "ok": any_ok,
                "candidates": results,
                "before_detected_profiles": before_detected,
                "after_detected_profiles": after_detected,
                "input_recovered": mic_now,
                "hint": (
                    "If ok is False, the unbind/rebind path needs sudo. "
                    "Run `bash shell_scripts/reset_anker_usb.sh --setup-sudoers` "
                    "once on the robot to install the sudoers entry, then retry."
                ) if not any_ok else None,
            }

        return await asyncio.to_thread(_do)
    finally:
        _USB_RESET_LOCK.release()
