"""AD-721b-1 — rhubarb-lip-sync subprocess wrapper.

License posture: rhubarb-lip-sync is MIT (verified 2026-05-12 via
``gh api repos/DanielSWolf/rhubarb-lip-sync/license``). ProbOS provides
this wrapper; the operator provides the binary at ``lipsync.binary_path``.
The repo never ships the binary — ``/tools/`` is gitignored.

Tier-2 log-and-degrade: every callable in this module returns False,
None, or an empty list on failure; ``CrewVRM`` (AD-721b-2) treats any
of those signals as the cue to fall back to the AD-721b v1 heuristic
path. None of these callables raise.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal

logger = logging.getLogger(__name__)

# AD-721b-1: rhubarb's Preston Blair 9-set → AD-721b v1 Oculus 15-set.
# rhubarb returns the 9 mouth shapes (A-H, X). The renderer in
# ``ui/src/audio/lipSyncTrack.ts:25-27`` consumes the Oculus 15-set.
# Mapping is intentional and lossy — multiple Oculus visemes collapse
# onto each Preston Blair shape on the way out, but the renderer uses
# only the vowel-set (aa/ih/ou/E/oh) for the morph weights anyway, so
# the consonant mapping is for completeness.
PrestonBlairViseme = Literal["A", "B", "C", "D", "E", "F", "G", "H", "X"]

OculusViseme = Literal[
    "sil", "PP", "FF", "TH", "DD", "kk", "CH", "SS", "nn", "RR",
    "aa", "E", "ih", "oh", "ou",
]

# AD-738c (Wave 158): base 1-to-1 mapping. The actual lookup function
# `_map_preston_blair_to_oculus` adds a duration-aware variant for the
# `B` shape — when a `B` frame exceeds 80 ms it almost always renders
# a short vowel sound (rhubarb misclassifies sustained "ih"/"uh" as
# `B`-shaped mouth). Short `B` frames keep the `kk` mapping because
# they really are stop consonants.
_PRESTON_BLAIR_TO_OCULUS: dict[str, str] = {
    "A": "PP",   # closed mouth — m/b/p
    "B": "kk",   # slightly open — k/g/n/t/d/s/z (DEFAULT — overridden for long frames; see _map_preston_blair_to_oculus)
    "C": "E",    # open mouth — e (as in "bed")
    "D": "aa",   # wide open — a (as in "father")
    "E": "oh",   # rounded — o (as in "go")
    "F": "ou",   # narrow — u (as in "you")
    "G": "FF",   # f/v
    "H": "RR",   # l/r
    "X": "sil",  # rest / silence
}

# AD-738c: duration threshold (milliseconds) above which a `B` frame is
# routed to a full vowel (`ih`) instead of the consonant default (`kk`).
# Empirical: 80 ms is the floor for sustained "ih"-class vowels in
# Piper Amy MIT @ 22050 Hz; stop consonants in the same voice peak
# at 60-75 ms.
_B_LONG_DURATION_MS: float = 80.0


@dataclass(frozen=True)
class VisemeFrame:
    """One viseme segment in the schedule.

    ``time`` and ``duration`` are seconds (rhubarb's native unit).
    ``viseme`` is the Oculus 15-set key consumed by the renderer.
    """

    time: float
    duration: float
    viseme: str


def _map_preston_blair_to_oculus(pb: str, duration_ms: float = 0.0) -> str:
    """Lookup with duration-aware override for ``B`` (AD-738c).

    Falls back to ``sil`` for any unknown shape (forward-compat if rhubarb
    adds a viseme — log-and-degrade rather than crash).

    When ``pb == "B"`` and ``duration_ms > _B_LONG_DURATION_MS`` (default
    80 ms), routes to ``"ih"`` (full vowel) instead of ``"kk"`` (consonant
    default). Rationale: rhubarb's ``B`` covers both short stop consonants
    AND short unstressed vowels that fall below the wider C/D/E shapes.
    Long B frames are almost always vowel-class sounds.

    Backward compat: callers that pass ``duration_ms=0.0`` (or omit the
    kwarg) get the legacy 1-to-1 mapping unchanged.
    """
    mapped = _PRESTON_BLAIR_TO_OCULUS.get(pb)
    if mapped is None:
        logger.warning(
            "AD-721b-1: unknown Preston Blair viseme %r; degrading to sil", pb
        )
        return "sil"
    if pb == "B" and duration_ms > _B_LONG_DURATION_MS:
        return "ih"
    return mapped


def _resolve_binary_path(configured: str) -> Path | None:
    """Resolve the configured binary path, auto-appending ``.exe`` on Windows
    if the literal path does not exist. Returns None if the binary cannot
    be located. NEVER raises."""
    p = Path(configured).resolve()
    if p.is_file():
        return p
    if sys.platform == "win32" and p.suffix.lower() != ".exe":
        # Append .exe alongside the existing name to avoid clobbering an
        # existing suffix (e.g. "rhubarb.bin" → "rhubarb.bin.exe", not
        # "rhubarb.exe"). Path.with_suffix would replace the suffix.
        with_exe = p.parent / (p.name + ".exe")
        if with_exe.is_file():
            return with_exe
    return None


def _resolve_ffmpeg_binary(configured: str) -> Path | None:
    """AD-721b-1a: resolve the configured ffmpeg binary path.

    Mirrors ``_resolve_binary_path`` (separate function so the two binary
    discovery paths stay independently overridable in tests). Auto-appends
    ``.exe`` on Windows. NEVER raises.
    """
    if not configured:
        return None
    p = Path(configured).resolve()
    if p.is_file():
        return p
    if sys.platform == "win32" and p.suffix.lower() != ".exe":
        with_exe = p.parent / (p.name + ".exe")
        if with_exe.is_file():
            return with_exe
    return None


async def _convert_to_wav(
    audio_path: Path,
    ffmpeg_binary: Path,
    *,
    timeout_seconds: float = 30.0,
) -> Path | None:
    """AD-721b-1a: convert ``audio_path`` to 16-bit mono 22050 Hz WAV via ffmpeg.

    Returns the temp WAV path on success, None on any failure. Caller MUST
    ``unlink(missing_ok=True)`` in finally. Tier-2 throughout - NEVER raises.

    BF-280: ``subprocess.Popen`` in thread executor (WindowsSelectorEventLoop
    does not support ``asyncio.create_subprocess_*``).
    BF-282: writes to a tempfile via ffmpeg's ``-y <path>`` argument; the
    binary's stdout is intentionally DEVNULL'd.
    """
    import tempfile

    tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
    tmp.close()
    temp_path = Path(tmp.name)
    args = [
        str(ffmpeg_binary),
        "-y",
        "-i", str(audio_path),
        "-ac", "1",
        "-ar", "22050",
        "-acodec", "pcm_s16le",
        str(temp_path),
    ]

    def _run_sync() -> tuple[int, bytes]:
        proc = subprocess.Popen(
            args,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.PIPE,
        )
        try:
            _, err = proc.communicate(timeout=timeout_seconds)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()
            raise
        return proc.returncode or 0, err

    try:
        loop = asyncio.get_running_loop()
        try:
            returncode, stderr_bytes = await loop.run_in_executor(None, _run_sync)
        except subprocess.TimeoutExpired:
            logger.warning(
                "AD-721b-1a: ffmpeg timed out after %ss converting %s",
                timeout_seconds, audio_path.name,
            )
            temp_path.unlink(missing_ok=True)
            return None
        if returncode != 0:
            logger.warning(
                "AD-721b-1a: ffmpeg exit=%s on %s; stderr=%s",
                returncode, audio_path.name,
                stderr_bytes.decode("utf-8", errors="replace")[:500],
            )
            temp_path.unlink(missing_ok=True)
            return None
        # Defense in depth: verify the output exists and is non-empty.
        if not temp_path.is_file() or temp_path.stat().st_size == 0:
            logger.warning(
                "AD-721b-1a: ffmpeg produced empty / missing output for %s",
                audio_path.name,
            )
            temp_path.unlink(missing_ok=True)
            return None
        return temp_path
    except (OSError, ValueError) as e:
        logger.warning(
            "AD-721b-1a: ffmpeg subprocess failed on %s: %s: %s",
            audio_path.name, type(e).__name__, e,
        )
        temp_path.unlink(missing_ok=True)
        return None


async def is_available(binary_path: str, timeout_seconds: float = 5.0) -> bool:
    """Check rhubarb is present at ``binary_path`` AND responds to ``--version``.

    Tier-2 log-and-degrade: any failure returns False. NEVER raises.
    """
    resolved = _resolve_binary_path(binary_path)
    if resolved is None:
        logger.info(
            "AD-721b-1: rhubarb binary not found at %s; degrading to heuristic",
            binary_path,
        )
        return False
    version = await probe_version(resolved, timeout_seconds=timeout_seconds)
    return version is not None


async def probe_version(
    binary_path: Path, timeout_seconds: float = 5.0
) -> str | None:
    """Run ``rhubarb --version`` and return the version string, or None on any
    failure. NEVER raises."""
    try:
        # BF-280 (2026-05-13): subprocess.Popen in thread executor to support
        # WindowsSelectorEventLoop (which lacks create_subprocess_*).
        def _probe_sync() -> tuple[int, bytes, bytes]:
            proc = subprocess.Popen(
                [str(binary_path), "--version"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
            try:
                out, err = proc.communicate(timeout=timeout_seconds)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
                raise
            return proc.returncode or 0, out, err

        loop = asyncio.get_running_loop()
        try:
            returncode, stdout, _stderr = await loop.run_in_executor(None, _probe_sync)
        except subprocess.TimeoutExpired:
            logger.warning(
                "AD-721b-1: rhubarb --version timed out after %ss", timeout_seconds
            )
            return None
        if returncode != 0:
            logger.warning(
                "AD-721b-1: rhubarb --version returned %s", returncode
            )
            return None
        return stdout.decode("utf-8", errors="replace").strip()
    except (OSError, ValueError) as e:
        logger.warning(
            "AD-721b-1: rhubarb --version failed: %s: %s", type(e).__name__, e
        )
        return None


async def generate_visemes(
    audio_path: Path,
    binary_path: str,
    timeout_seconds: float = 30.0,
    *,
    ffmpeg_binary_path: str | None = None,
) -> list[VisemeFrame]:
    """Run rhubarb on ``audio_path``, return Oculus-mapped viseme schedule.

    Returns ``[]`` on ANY failure (binary missing, subprocess error, malformed
    JSON, timeout). The empty-list contract is the signal to the caller
    (router) to fall through to the heuristic path. NEVER raises.

    The wav file at ``audio_path`` MUST be a path resolved through
    ``AttachmentStore.get_path()`` so it's already sandboxed under the
    platform data dir (AD-720 path-traversal guard).

    AD-721b-1a: when ``audio_path`` is not WAV/OGG and ``ffmpeg_binary_path``
    is provided + resolvable, the audio is transcoded to 16-bit mono 22050 Hz
    WAV before being handed to rhubarb. The temp WAV is unlinked in finally.
    If ffmpeg is missing or conversion fails, the BF-292 honest-degrade
    contract is preserved (returns ``[]``).
    """
    if not audio_path.is_file():
        logger.warning("AD-721b-1: audio path missing: %s", audio_path)
        return []

    # BF-292: rhubarb only accepts WAV and OGG. Browser MediaRecorder
    # captures default to audio/webm (Chrome's only supported codec for
    # MediaRecorder); without server-side ffmpeg conversion, every captured
    # clip would fail with "Error processing file" + exit 1. Reject at the
    # boundary, honest-degrade to the heuristic path. INFO not WARNING —
    # this is wrong-input, not a runtime fault. Forward marker AD-721b-1a
    # tracks adding ffmpeg-backed format conversion for webm/m4a/mp3.
    _SUPPORTED_SUFFIXES = {".wav", ".ogg"}
    converted_temp: Path | None = None
    if audio_path.suffix.lower() not in _SUPPORTED_SUFFIXES:
        # AD-721b-1a: try ffmpeg conversion. If ffmpeg missing or fails,
        # fall through to BF-292's honest-degrade path (return []).
        ffmpeg_binary = (
            _resolve_ffmpeg_binary(ffmpeg_binary_path) if ffmpeg_binary_path else None
        )
        if ffmpeg_binary is not None:
            converted_temp = await _convert_to_wav(audio_path, ffmpeg_binary)
        if converted_temp is None:
            logger.info(
                "AD-721b-1: rhubarb skipped — unsupported audio format %s on %s "
                "(rhubarb accepts WAV/OGG only; client should convert before "
                "POSTing to /api/avatars/lipsync, or operator can install ffmpeg "
                "for server-side conversion via AD-721b-1a)",
                audio_path.suffix, audio_path.name,
            )
            return []
        audio_path = converted_temp  # use the converted file for the rest
    try:
        return await _run_rhubarb(audio_path, binary_path, timeout_seconds)
    finally:
        if converted_temp is not None:
            converted_temp.unlink(missing_ok=True)


async def _run_rhubarb(
    audio_path: Path,
    binary_path: str,
    timeout_seconds: float,
) -> list[VisemeFrame]:
    """AD-721b-1a: extracted rhubarb invocation so generate_visemes can wrap
    it in a finally that unlinks the optional ffmpeg-converted tempfile.
    """
    resolved_binary = _resolve_binary_path(binary_path)
    if resolved_binary is None:
        logger.warning(
            "AD-721b-1: rhubarb binary not found at %s; returning empty schedule",
            binary_path,
        )
        return []
    try:
        # BF-280 (2026-05-13): subprocess.Popen in thread executor.
        # BF-284 (2026-05-13): use --recognizer phonetic instead of the default
        # pocketSphinx. PocketSphinx is ~2.5x slower because it runs a full
        # English ASR pass with language modeling; phonetic is purely acoustic
        # and language-agnostic. Our pipeline collapses rhubarb's output to the
        # Preston Blair 9-set anyway (rhubarb_backend.py:41) and the renderer
        # further collapses to 5 vowel axes, so the consonant-distinction loss
        # from phonetic is invisible at the rendered-mouth level.
        # BF-290 (2026-05-15): pass an explicit positive thread count. Some
        # rhubarb releases (observed: rhubarb-lip-sync 1.13.0) reject
        # ``--threads 0`` with "Thread count must be 1 or higher" and exit 1.
        # Older docs implied 0 = "all cores"; current binary requires a
        # positive integer. Use os.cpu_count() (or 1 fallback) so the
        # behaviour is identical to the original intent on every binary
        # version. Capped at 16 because rhubarb is single-stream and gains
        # nothing beyond a handful of cores.
        _threads = max(1, min(16, os.cpu_count() or 1))
        def _run_sync() -> tuple[int, bytes, bytes]:
            proc = subprocess.Popen(
                [
                    str(resolved_binary),
                    "-r", "phonetic",
                    "--threads", str(_threads),
                    "-f", "json",
                    str(audio_path),
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
            try:
                out, err = proc.communicate(timeout=timeout_seconds)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
                raise
            return proc.returncode or 0, out, err

        loop = asyncio.get_running_loop()
        try:
            returncode, stdout_bytes, stderr_bytes = await loop.run_in_executor(
                None, _run_sync,
            )
        except subprocess.TimeoutExpired:
            logger.warning(
                "AD-721b-1: rhubarb timed out after %ss on %s; degrading",
                timeout_seconds, audio_path.name,
            )
            return []
        if returncode != 0:
            logger.warning(
                "AD-721b-1: rhubarb exit=%s on %s; stderr=%s",
                returncode, audio_path.name,
                stderr_bytes.decode("utf-8", errors="replace")[:500],
            )
            return []
    except (OSError, ValueError) as e:
        logger.warning(
            "AD-721b-1: rhubarb subprocess failed on %s: %s: %s",
            audio_path.name, type(e).__name__, e,
        )
        return []

    try:
        payload = json.loads(stdout_bytes.decode("utf-8", errors="replace"))
    except json.JSONDecodeError as e:
        logger.warning(
            "AD-721b-1: rhubarb returned malformed JSON on %s: %s",
            audio_path.name, e,
        )
        return []

    cues = payload.get("mouthCues") if isinstance(payload, dict) else None
    if not isinstance(cues, list):
        logger.warning(
            "AD-721b-1: rhubarb JSON missing mouthCues on %s", audio_path.name
        )
        return []

    return _parse_rhubarb_output(payload)


def _parse_rhubarb_output(payload: dict) -> list[VisemeFrame]:
    """Convert a parsed rhubarb JSON payload into VisemeFrame list.

    AD-738c (Wave 158): extracted so the duration-aware Preston-Blair
    mapping is unit-testable without a subprocess. Silently skips malformed
    cues (defense in depth — rhubarb output has been observed to drop
    fields under rare conditions).
    """
    cues = payload.get("mouthCues") if isinstance(payload, dict) else None
    if not isinstance(cues, list):
        return []
    frames: list[VisemeFrame] = []
    for cue in cues:
        if not isinstance(cue, dict):
            continue
        start = cue.get("start")
        end = cue.get("end")
        value = cue.get("value")
        if not (isinstance(start, (int, float)) and isinstance(end, (int, float))):
            continue
        if not isinstance(value, str):
            continue
        if end < start:
            continue
        duration_s = float(end - start)
        frames.append(
            VisemeFrame(
                time=float(start),
                duration=duration_s,
                viseme=_map_preston_blair_to_oculus(value, duration_ms=duration_s * 1000.0),
            )
        )
    return frames
