"""Audio device profiles + pactl detection + selection persistence.

Manages multiple audio device profiles (generic built-in, Hollyland wireless
mic + built-in speaker, Anker PowerConf) and lets the dashboard switch
between them at runtime. Selection is persisted to data/audio_device.json
so the choice survives restart.

Resolution policy:
  1. User-selected profile (from data/audio_device.json) — if its sink/source
     is currently plugged in, use it.
  2. Auto-detected profile based on what is currently plugged in.
  3. Built-in fallback.

Each profile has:
  - id: short identifier
  - label: human-readable name
  - match: substring used to find the actual pactl name (since exact names
    contain serial numbers and may differ between machines)
  - sink_pattern: substring matched against pactl sink names
  - source_pattern: substring matched against pactl source names
  - sample_rate / channels (optional defaults — read by AudioManager)
"""

from __future__ import annotations

import json
import os
import subprocess
import tempfile
import threading
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any

from Project.Sanad.config import DATA_DIR
from Project.Sanad.core.logger import get_logger

log = get_logger("audio_devices")

DEVICE_STATE_FILE = DATA_DIR / "audio_device.json"
_LOCK = threading.Lock()


@dataclass
class AudioProfile:
    id: str
    label: str
    sink_pattern: str         # substring used to find a sink
    source_pattern: str       # substring used to find a source
    description: str = ""
    sink_sample_rate: int = 0  # 0 = use device default
    source_sample_rate: int = 0


# Built-in device profiles.
#
# MATCHING RULES:
#   - Patterns are matched case-insensitively against the FULL PulseAudio name.
#   - Multiple patterns per field: comma-separated → match ANY.
#   - PulseAudio names change depending on the USB port, so we match the
#     product-name portion only (not the serial or port suffix).
#   - Order matters: the FIRST profile whose sink AND source both match
#     becomes the auto-default when no explicit selection is saved.
#
# Example PulseAudio names:
#   alsa_output.platform-sound.analog-stereo                        (built-in speaker)
#   alsa_input.platform-sound.analog-stereo                         (built-in mic)
#   alsa_output.usb-Anker_PowerConf_A3321-DEV-SN1-01.analog-stereo (Anker speaker — SN1-01 is port-dependent)
#   alsa_input.usb-Anker_PowerConf_A3321-DEV-SN1-01.mono-fallback  (Anker mic)
#   alsa_input.usb-Shenzhen_Hollyland_Technology_Co._Ltd_Wireless_microphone_C63X223T6MX-01.analog-stereo
#                                                                   (Hollyland mic — C63X... is serial-dependent)

PROFILES: list[AudioProfile] = [
    AudioProfile(
        id="builtin",
        label="Built-in mic + speaker",
        sink_pattern="platform-sound",
        source_pattern="alsa_input.platform-sound",
        description="Jetson / G1 built-in audio chip. (Default)",
    ),
    AudioProfile(
        id="hollyland_builtin",
        label="Hollyland mic + built-in speaker",
        sink_pattern="platform-sound",
        source_pattern="hollyland,wireless_microphone",
        description="Hollyland wireless lavalier microphone with the Jetson built-in speaker.",
    ),
    AudioProfile(
        id="anker_powerconf",
        label="Anker PowerConf (mic + speaker)",
        sink_pattern="powerconf,anker",
        source_pattern="powerconf,anker",
        description="Anker PowerConf USB conference unit — mic + speaker on the same device.",
    ),
]

# The profile that should be used when no saved state and no auto-detect succeeds.
DEFAULT_PROFILE_ID = "builtin"

PROFILES_BY_ID: dict[str, AudioProfile] = {p.id: p for p in PROFILES}


# ───────────────────────── pactl helpers ─────────────────────────

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


def pactl_available() -> bool:
    try:
        r = _run_pactl(["info"])
        return r.returncode == 0
    except (FileNotFoundError, subprocess.SubprocessError):
        return False


def list_sinks() -> list[dict[str, str]]:
    """Return [{name, description, index}] for every sink."""
    return _list_kind("sinks")


def list_sources() -> list[dict[str, str]]:
    return _list_kind("sources")


def _list_kind(kind: str) -> list[dict[str, str]]:
    out: list[dict[str, str]] = []
    try:
        short = _run_pactl(["list", "short", kind])
    except (FileNotFoundError, subprocess.SubprocessError) as exc:
        log.warning("pactl list %s failed: %s", kind, exc)
        return out
    if short.returncode != 0:
        return out
    for raw in (short.stdout or "").splitlines():
        parts = raw.split("\t")
        if len(parts) < 2:
            parts = raw.split()
            if len(parts) < 2:
                continue
        idx, name = parts[0], parts[1]
        out.append({"index": idx, "name": name, "description": _description_for(kind, name)})
    return out


def _description_for(kind: str, name: str) -> str:
    """Best-effort `pactl list <kind>s` to extract Description."""
    try:
        r = _run_pactl(["list", kind])
    except (FileNotFoundError, subprocess.SubprocessError):
        return ""
    if r.returncode != 0:
        return ""
    block: list[str] = []
    found = False
    for line in (r.stdout or "").splitlines():
        if line.startswith(("Sink #", "Source #")):
            if found:
                break
            block = []
        elif line.strip().startswith("Name:") and line.strip().endswith(name):
            found = True
        block.append(line)
    if not found:
        return ""
    for line in block:
        s = line.strip()
        if s.startswith("Description:"):
            return s.split(":", 1)[1].strip()
    return ""


def get_default_sink() -> str:
    try:
        r = _run_pactl(["get-default-sink"])
        return (r.stdout or "").strip() if r.returncode == 0 else ""
    except (FileNotFoundError, subprocess.SubprocessError):
        return ""


def get_default_source() -> str:
    try:
        r = _run_pactl(["get-default-source"])
        return (r.stdout or "").strip() if r.returncode == 0 else ""
    except (FileNotFoundError, subprocess.SubprocessError):
        return ""


def set_default_sink(name: str) -> bool:
    try:
        r = _run_pactl(["set-default-sink", name])
        return r.returncode == 0
    except (FileNotFoundError, subprocess.SubprocessError):
        return False


def set_default_source(name: str) -> bool:
    try:
        r = _run_pactl(["set-default-source", name])
        return r.returncode == 0
    except (FileNotFoundError, subprocess.SubprocessError):
        return False


# ───────────────────────── matching ─────────────────────────

def find_first_match(items: list[dict[str, str]], pattern: str,
                     exclude_monitors: bool = False) -> dict[str, str] | None:
    """Return first item whose name (case-insensitive) contains ANY of the
    comma-separated patterns.

    Example: pattern="powerconf,anker" matches any name containing
    "powerconf" OR "anker" (case-insensitive).

    If exclude_monitors=True, skip PulseAudio monitor sources (names ending
    in ".monitor") so we don't accidentally pick a loopback instead of a real mic.
    """
    if not pattern:
        return None
    needles = [p.strip().lower() for p in pattern.split(",") if p.strip()]
    if not needles:
        return None
    for it in items:
        name_lower = it["name"].lower()
        if exclude_monitors and name_lower.endswith(".monitor"):
            continue
        for needle in needles:
            if needle in name_lower:
                return it
    return None


def detect_plugged_profiles() -> list[dict[str, Any]]:
    """Return all profiles whose sink AND source are currently plugged in."""
    sinks = list_sinks()
    sources = list_sources()
    detected: list[dict[str, Any]] = []
    for prof in PROFILES:
        sink = find_first_match(sinks, prof.sink_pattern)
        src = find_first_match(sources, prof.source_pattern, exclude_monitors=True)
        if sink and src:
            detected.append({
                "profile": asdict(prof),
                "sink": sink,
                "source": src,
            })
    return detected


# ───────────────────────── persistence ─────────────────────────

def load_state() -> dict[str, Any]:
    """Load saved selection. Always returns a dict."""
    if not DEVICE_STATE_FILE.exists():
        return {}
    try:
        with open(DEVICE_STATE_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError) as exc:
        log.warning("audio_device.json unreadable: %s", exc)
        return {}


def save_state(state: dict[str, Any]) -> None:
    """Atomic write of audio_device.json."""
    DEVICE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    with _LOCK:
        fd, tmp = tempfile.mkstemp(
            prefix=f".{DEVICE_STATE_FILE.name}.", suffix=".tmp",
            dir=str(DEVICE_STATE_FILE.parent),
        )
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(state, f, indent=2)
            os.replace(tmp, DEVICE_STATE_FILE)
        except Exception:
            try:
                os.unlink(tmp)
            except OSError:
                pass
            raise


# ───────────────────────── current selection ─────────────────────────

def current_selection() -> dict[str, Any]:
    """Resolve the currently active sink/source.

    Order:
      1. Saved profile selection (if its sink/source still plugged)
      2. Saved explicit sink/source pair
      3. DEFAULT profile (builtin) if detected
      4. First detected profile (in declaration order)
      5. pactl defaults
      6. Empty
    """
    state = load_state()

    # Detected profiles snapshot
    detected = detect_plugged_profiles() if pactl_available() else []
    detected_by_id = {d["profile"]["id"]: d for d in detected}

    # 1. Saved profile preference
    saved_profile = state.get("profile_id")
    if saved_profile and saved_profile in detected_by_id:
        d = detected_by_id[saved_profile]
        return {
            "source_kind": "profile",
            "profile": d["profile"],
            "sink": d["sink"]["name"],
            "source": d["source"]["name"],
            "sink_description": d["sink"]["description"],
            "source_description": d["source"]["description"],
        }

    # 2. Saved explicit sink/source
    if state.get("sink") and state.get("source"):
        return {
            "source_kind": "manual",
            "profile": None,
            "sink": state["sink"],
            "source": state["source"],
            "sink_description": "",
            "source_description": "",
        }

    # 3. Default profile if it is plugged in
    if DEFAULT_PROFILE_ID in detected_by_id:
        d = detected_by_id[DEFAULT_PROFILE_ID]
        return {
            "source_kind": "default",
            "profile": d["profile"],
            "sink": d["sink"]["name"],
            "source": d["source"]["name"],
            "sink_description": d["sink"]["description"],
            "source_description": d["source"]["description"],
        }

    # 4. First detected profile (in declaration order)
    if detected:
        d = detected[0]
        return {
            "source_kind": "auto",
            "profile": d["profile"],
            "sink": d["sink"]["name"],
            "source": d["source"]["name"],
            "sink_description": d["sink"]["description"],
            "source_description": d["source"]["description"],
        }

    # 5. pactl defaults (system-wide)
    sink = get_default_sink()
    source = get_default_source()
    if sink and source:
        return {
            "source_kind": "pactl_default",
            "profile": None,
            "sink": sink,
            "source": source,
            "sink_description": "",
            "source_description": "",
        }

    # 6. Empty
    return {
        "source_kind": "none",
        "profile": None,
        "sink": "",
        "source": "",
        "sink_description": "",
        "source_description": "",
    }


# ───────────────────────── apply selection ─────────────────────────

def apply_selection(sink: str, source: str) -> dict[str, Any]:
    """Run pactl set-default-* and unmute. Returns {ok, errors}."""
    errors: list[str] = []
    if sink:
        if not set_default_sink(sink):
            errors.append(f"set-default-sink failed: {sink}")
        else:
            try:
                _run_pactl(["set-sink-mute", sink, "0"])
            except (FileNotFoundError, subprocess.SubprocessError):
                pass
    if source:
        if not set_default_source(source):
            errors.append(f"set-default-source failed: {source}")
        else:
            try:
                _run_pactl(["set-source-mute", source, "0"])
            except (FileNotFoundError, subprocess.SubprocessError):
                pass
    return {"ok": not errors, "errors": errors}


def apply_current_selection() -> dict[str, Any]:
    """Resolve the current device selection (re-scanning all USB ports) and
    apply it via pactl. Called at AudioManager startup and when devices change.

    This is the key function that makes audio work regardless of which USB
    port the device is plugged into — it re-discovers on every call.
    """
    if not pactl_available():
        return {"ok": False, "error": "pactl not available"}
    cur = current_selection()
    sink = cur.get("sink", "")
    source = cur.get("source", "")
    if not sink and not source:
        return {"ok": False, "error": "no device resolved", "selection": cur}
    result = apply_selection(sink, source)
    result["selection"] = cur
    if result["ok"]:
        log.info("Audio applied — sink=%s source=%s (via %s)",
                 sink, source, cur.get("source_kind", "?"))
    else:
        log.warning("Audio apply partial — sink=%s source=%s errors=%s",
                    sink, source, result["errors"])
    return result


def select_profile(profile_id: str) -> dict[str, Any]:
    """Switch to a named profile. Persists selection."""
    if profile_id not in PROFILES_BY_ID:
        return {"ok": False, "error": f"Unknown profile: {profile_id}"}

    detected = detect_plugged_profiles()
    detected_by_id = {d["profile"]["id"]: d for d in detected}
    if profile_id not in detected_by_id:
        return {
            "ok": False,
            "error": f"Profile '{profile_id}' is not currently plugged in",
            "available": [d["profile"]["id"] for d in detected],
        }

    d = detected_by_id[profile_id]
    sink_name = d["sink"]["name"]
    source_name = d["source"]["name"]

    apply_result = apply_selection(sink_name, source_name)
    if not apply_result["ok"]:
        return {"ok": False, "errors": apply_result["errors"]}

    save_state({
        "profile_id": profile_id,
        "sink": sink_name,
        "source": source_name,
    })
    log.info("Selected audio profile: %s (sink=%s, source=%s)", profile_id, sink_name, source_name)
    return {
        "ok": True,
        "profile": d["profile"],
        "sink": sink_name,
        "source": source_name,
    }


def select_manual(sink: str, source: str) -> dict[str, Any]:
    """Switch to an explicit sink/source pair (no profile)."""
    apply_result = apply_selection(sink, source)
    if not apply_result["ok"]:
        return {"ok": False, "errors": apply_result["errors"]}
    save_state({"profile_id": None, "sink": sink, "source": source})
    log.info("Selected manual audio: sink=%s source=%s", sink, source)
    return {"ok": True, "sink": sink, "source": source}


# ───────────────────────── status ─────────────────────────

def status() -> dict[str, Any]:
    """One-shot status for the dashboard."""
    pa = pactl_available()
    detected = detect_plugged_profiles() if pa else []
    detected_ids = [d["profile"]["id"] for d in detected]
    cur = current_selection()
    return {
        "pactl_available": pa,
        "current": cur,
        "saved_state": load_state(),
        "profiles": [asdict(p) for p in PROFILES],
        "detected": detected,
        "detected_ids": detected_ids,
        "all_sinks": list_sinks() if pa else [],
        "all_sources": list_sources() if pa else [],
        "default_sink": get_default_sink() if pa else "",
        "default_source": get_default_source() if pa else "",
    }
