"""Microphone capture — PROXY to an out-of-process audio helper.

Public surface (`start`, `stop`, `is_recording`, `get_levels`) is
identical to the previous in-process implementation, so app.py and the
overlay don't change. Internally, all CoreAudio interaction has been
moved into a subprocess (`src/audio_helper.py`) for the reasons documented
at the top of that file: macOS 15.x has a recurring CoreAudio HAL mutex
deadlock (likely AOP firmware) that can wedge `stream.stop()` for hours.
With everything in one process, that wedge paralyses the main thread (we
sampled this happening live: main thread stuck inside
PyThread_acquire_lock_timed → recorder._lock → held by Timer doing
stream.close()). With the subprocess split, only the helper hangs;
parent kills+respawns it.

Failure handling
----------------
A watchdog thread in the parent pings the helper every 3 seconds. If
the PONG hasn't come back within 1 second, the helper is presumed
wedged → `proc.kill()` (SIGKILL) → respawn within ~200 ms. New helper
opens a fresh CoreAudio stream (CoreAudio is per-process, so the
poisoned HAL state dies with the old helper).

`get_levels()` returns the most recent levels pushed by the helper
during armed periods — overlay's visualisation only renders while
recording, so a brief gap during a respawn is invisible.
"""

from __future__ import annotations

import collections
import os
import struct
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional

import numpy as np


# Watchdog tunables — picked deliberately:
_PING_INTERVAL_SEC = 3.0      # how often we poll the helper for life
_PING_TIMEOUT_SEC = 1.0       # how long we wait for a PONG before declaring dead
_DISARM_AUDIO_TIMEOUT_SEC = 5.0  # max we'll wait for the helper to return audio


def _audio_helper_argv() -> list[str]:
    """Build the command line to (re)spawn the audio helper.

    Frozen .app: respawn via the py2app bootloader (Contents/MacOS/Lirelle),
    which sets PYTHONHOME / PYTHONPATH correctly for the bundled python
    and then runs lumo_main.py. lumo_main detects `--audio-helper` in
    argv at module top and routes into the helper before any heavy
    imports — the audio child boots in well under a second.

    Dev: invoke lumo_main.py with the venv python directly. The dev
    venv's python already has the right sys.path / site-packages.
    """
    here = Path(__file__).resolve().parent.parent
    if getattr(sys, "frozen", False):
        # __file__ here = .../Contents/Resources/src/recorder.py
        # → here       = .../Contents/Resources
        # → here.parent = .../Contents → /MacOS/<exec> is the bootloader.
        # The executable name follows setup.py's CFBundleExecutable, which
        # we set to "Lirelle" during the rebrand. Keep "Lumo" as a backward-
        # compat fallback so an old build that wasn't rebuilt yet still
        # works. Without this loop, post-rebrand builds fell back to
        # sys.executable, which respawns the Python interpreter directly —
        # that boots but loses py2app's `sys.frozen` flag, breaking helper
        # init paths.
        for exe_name in ("Lirelle", "Lumo"):
            bootloader = here.parent / "MacOS" / exe_name
            if bootloader.exists():
                return [str(bootloader), "--audio-helper"]
        # Last-ditch fallback — better to fail loudly later than to crash init.
        print(f"[recorder] WARNING: no bootloader at {here.parent / 'MacOS'};"
              f" falling back to sys.executable", flush=True)
    lumo_main = here / "lumo_main.py"
    return [sys.executable, str(lumo_main), "--audio-helper"]


class Recorder:
    """Proxy to the out-of-process audio helper. Public surface matches
    the legacy in-process Recorder so app.py is unchanged."""

    def __init__(self, sample_rate: int = 16000, channels: int = 1, device=None):  # noqa: ARG002
        # sample_rate/channels/device kept for source compatibility — the
        # helper hard-codes 16 kHz mono for now (matches Whisper's input).
        self.sample_rate = sample_rate
        self.channels = channels
        self._proc: Optional[subprocess.Popen] = None
        self._proc_lock = threading.Lock()  # serialises spawn / respawn / send
        self._levels: collections.deque[float] = collections.deque(maxlen=240)
        self._levels_lock = threading.Lock()
        self._armed = False

        # DISARM response plumbing — _stop_event signals "audio frame received",
        # _stop_audio holds the np.ndarray when ready.
        self._stop_event = threading.Event()
        self._stop_audio: np.ndarray = np.zeros(0, dtype=np.float32)
        self._stop_lock = threading.Lock()  # protects _stop_audio assignment

        # PING/PONG plumbing for the watchdog.
        self._pong_event = threading.Event()

        # Hands-free / VAD plumbing. When `start_with_vad()` is in effect,
        # the helper streams every callback chunk as b'C' frames and we
        # run webrtcvad on them to auto-DISARM on sustained silence. The
        # `_on_auto_stop` callable fires once when the silence threshold
        # is met — app.py wires it to the normal stop-and-transcribe path.
        self._vad_active = False
        self._vad_on_auto_stop: Optional[callable] = None  # type: ignore[valid-type]

        self._reader_thread: Optional[threading.Thread] = None
        self._watchdog_thread: Optional[threading.Thread] = None
        self._shutdown = False

        self._spawn_helper()
        self._watchdog_thread = threading.Thread(target=self._watchdog_loop, daemon=True)
        self._watchdog_thread.start()

    # ── helper lifecycle ─────────────────────────────────────────────────

    def _spawn_helper(self) -> None:
        """Start (or restart) the helper subprocess. Caller must hold
        _proc_lock OR be in __init__ before any other thread sees self."""
        # Don't leave a zombie if respawning over a dead one.
        if self._proc is not None:
            try:
                self._proc.kill()
            except Exception:
                pass
            try:
                self._proc.wait(timeout=1.0)
            except Exception:
                pass
            self._proc = None

        argv = _audio_helper_argv()
        # Inherit our environment — TCC mic permission is per-bundle and
        # the child inherits the parent's accepted-app status.
        env = dict(os.environ)
        # Tell the child to never redirect its stdout to a log file
        # (lumo_main.py does that for the main app). The child uses
        # stdout for binary IPC.
        env["LUMO_AUDIO_HELPER"] = "1"
        # Redirect helper's stderr to a log file we can post-mortem. Originally
        # captured to a pipe + never drained — could fill the pipe buffer
        # (64 KB on macOS) and block the helper indefinitely on any stderr
        # write. File is opened append-only so multiple spawns concatenate.
        helper_log_path = os.path.expanduser("~/Library/Logs/Lirelle_audio_helper.log")
        try:
            os.makedirs(os.path.dirname(helper_log_path), exist_ok=True)
            helper_stderr = open(helper_log_path, "a", buffering=1)
        except Exception:
            helper_stderr = subprocess.DEVNULL
        try:
            self._proc = subprocess.Popen(
                argv,
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=helper_stderr,
                bufsize=0,                # unbuffered — we frame ourselves
                env=env,
            )
        except Exception as e:
            print(f"[recorder] failed to spawn helper: {e}", flush=True)
            self._proc = None
            return

        self._reader_thread = threading.Thread(
            target=self._reader_loop, args=(self._proc,), daemon=True,
        )
        self._reader_thread.start()
        print(f"[recorder] audio helper spawned: pid={self._proc.pid}", flush=True)

    def _send_cmd(self, cmd: bytes) -> bool:
        """Write a one-byte command to the helper. Returns False if the
        helper is dead/broken."""
        with self._proc_lock:
            if self._proc is None or self._proc.stdin is None:
                return False
            try:
                self._proc.stdin.write(cmd)
                self._proc.stdin.flush()
                return True
            except (BrokenPipeError, OSError) as e:
                print(f"[recorder] send_cmd({cmd!r}) failed: {e}", flush=True)
                return False

    # ── reader thread — drains the helper's stdout ─────────────────────

    @staticmethod
    def _read_exact(stream, n: int) -> bytes | None:
        """Read EXACTLY n bytes from a pipe — looping until satisfied or EOF.
        `BufferedReader.read(n)` on a pipe can short-return (split syscalls,
        partial fills), which would desync our length-prefixed protocol and
        cause every subsequent frame to be misinterpreted as the tail of
        the previous one. Hit this on the first dev smoke."""
        buf = bytearray()
        while len(buf) < n:
            chunk = stream.read(n - len(buf))
            if not chunk:
                return None  # EOF
            buf += chunk
        return bytes(buf)

    def _reader_loop(self, proc: subprocess.Popen) -> None:
        """Read length-prefixed frames from the helper. One frame per loop
        iteration. Exits when the helper closes its stdout (it died or we
        killed it)."""
        out = proc.stdout
        assert out is not None
        while True:
            hdr = self._read_exact(out, 5)  # 1 byte type + 4 bytes LE length
            if hdr is None:
                break
            ftype = hdr[0:1]
            (plen,) = struct.unpack("<I", hdr[1:5])
            if plen > 0:
                payload = self._read_exact(out, plen)
                if payload is None:
                    break  # helper died mid-frame
            else:
                payload = b""

            if ftype == b"L":
                # Level update — N float32 RMS samples.
                count = plen // 4
                if count > 0:
                    samples = struct.unpack(f"<{count}f", payload)
                    with self._levels_lock:
                        self._levels.extend(samples)
            elif ftype == b"C":
                # Streaming audio chunk during VAD-armed recording. Forward
                # to the VAD worker only if we asked for streaming — if the
                # helper is still sending chunks from a stale ARM-V, drop them.
                if self._vad_active:
                    self._vad_feed_chunk(payload)
            elif ftype == b"A":
                # DISARM response — audio frame.
                if plen > 0:
                    audio = np.frombuffer(payload, dtype=np.float32).copy()
                else:
                    audio = np.zeros(0, dtype=np.float32)
                with self._stop_lock:
                    self._stop_audio = audio
                self._stop_event.set()
            elif ftype == b"O":
                # PONG — watchdog liveness reply.
                self._pong_event.set()
            elif ftype == b"E":
                msg = payload.decode("utf-8", errors="replace")
                print(f"[helper] {msg}", flush=True)
            # silently ignore unknown frame types — forward compat

        # Pipe closed. If we hadn't already received an audio frame for
        # an in-flight DISARM, unblock the caller with empty audio.
        if self._armed:
            with self._stop_lock:
                self._stop_audio = np.zeros(0, dtype=np.float32)
            self._stop_event.set()

    # ── watchdog ────────────────────────────────────────────────────────

    def _watchdog_loop(self) -> None:
        """Ping the helper every _PING_INTERVAL_SEC. If no PONG within
        _PING_TIMEOUT_SEC, kill it and respawn. Catches the exact wedge
        scenario that motivated this whole rewrite — a stuck CoreAudio
        call in the helper that would otherwise survive forever."""
        while not self._shutdown:
            time.sleep(_PING_INTERVAL_SEC)
            if self._shutdown:
                break
            self._pong_event.clear()
            if not self._send_cmd(b"P"):
                # send failed — helper already gone, respawn.
                with self._proc_lock:
                    self._spawn_helper()
                continue
            if self._pong_event.wait(_PING_TIMEOUT_SEC):
                continue  # alive
            # No PONG. Declare wedged and respawn.
            print(f"[recorder] helper unresponsive — restarting", flush=True)
            with self._proc_lock:
                self._spawn_helper()
            # CR #10 — re-arm the fresh helper if we were mid-recording so
            # the rest of the utterance still gets captured. Previously we
            # left _armed=True but the new helper was idle: everything
            # said after the kill silently dropped. The leading audio
            # captured before the kill is already lost (it lived in the
            # old helper's stdout buffer), but at least the user gets the
            # tail half of the sentence instead of zero text.
            if self._armed:
                cmd = b"V" if self._vad_active else b"A"
                if self._send_cmd(cmd):
                    print(f"[recorder] re-armed new helper with {cmd!r} "
                          f"after respawn", flush=True)
                else:
                    print(f"[recorder] failed to re-arm helper after respawn",
                          flush=True)

    # ── VAD (hands-free) plumbing ──────────────────────────────────────

    # webrtcvad operates on 10/20/30 ms PCM16 frames at 8/16/32/48 kHz.
    # We feed it 30 ms frames at 16 kHz — same rate the helper captures.
    # webrtcvad aggressiveness 0=lenient, 3=strictest. Lowered 2→1 on
    # 2026-06-22 because aggressiveness 2 was misclassifying quiet
    # consonants / end-of-phrase fade as silence, contributing to the
    # "speaking continuously, hands-free cut off after 20 s" report.
    # Level 1 is more permissive on soft speech; the silence-threshold
    # (5 s) still rejects pure ambient noise quickly.
    _VAD_AGGRESSIVENESS = 1
    _VAD_FRAME_MS = 30
    _VAD_SAMPLES_PER_FRAME = 16000 * _VAD_FRAME_MS // 1000   # 480 samples

    def _vad_feed_chunk(self, payload: bytes) -> None:
        """Reader thread hands incoming streaming chunks here. We accumulate
        bytes into the pending buffer (chunks may not align to 30ms frames),
        run webrtcvad on every complete frame, and fire `_on_auto_stop` once
        the silence threshold is met."""
        # Decode float32 → int16 (webrtcvad expects PCM16).
        samples = np.frombuffer(payload, dtype=np.float32)
        if samples.size == 0:
            return
        pcm16 = np.clip(samples, -1.0, 1.0) * 32767.0
        self._vad_pending.extend(pcm16.astype(np.int16).tobytes())

        frame_bytes = self._VAD_SAMPLES_PER_FRAME * 2  # int16 = 2 bytes
        while len(self._vad_pending) >= frame_bytes:
            frame = bytes(self._vad_pending[:frame_bytes])
            del self._vad_pending[:frame_bytes]
            try:
                is_speech = self._vad.is_speech(frame, 16000)
            except Exception as e:
                print(f"[recorder] vad.is_speech error: {e}", flush=True)
                return
            self._vad_total_ms += self._VAD_FRAME_MS
            if is_speech:
                self._vad_silence_ms = 0
                self._vad_saw_speech = True
            else:
                self._vad_silence_ms += self._VAD_FRAME_MS

            # Auto-stop conditions:
            #   - we've heard speech at least once (so a dead mic / pure
            #     ambient won't immediately tear down the recording)
            #   - cumulative recording is at least _vad_min_recording_ms
            #     (~500ms) so the very first frames of background noise
            #     don't trigger a false stop
            #   - then: _vad_silence_ms ≥ _vad_silence_threshold_ms (~800ms)
            #     of unbroken silence after the last speech
            if (
                self._vad_saw_speech
                and self._vad_total_ms >= self._vad_min_recording_ms
                and self._vad_silence_ms >= self._vad_silence_threshold_ms
            ):
                cb = self._vad_on_auto_stop
                # Mark inactive BEFORE invoking the callback so re-entry
                # (callback → app.stop() → recorder.stop()) doesn't double-fire.
                self._vad_active = False
                self._vad_on_auto_stop = None
                if cb is not None:
                    try:
                        cb()
                    except Exception as e:
                        print(f"[recorder] vad on_auto_stop error: {e}", flush=True)
                return

    def start_with_vad(
        self,
        on_auto_stop,
        silence_threshold_ms: int = 5000,
        min_recording_ms: int = 500,
    ) -> None:
        # silence_threshold_ms default raised 800 → 2000 → 5000ms (2026-06-22).
        # Each bump came from a user-report of being clipped mid-sentence on
        # natural thinking pauses. 5 s catches "she's actually done" reliably
        # while letting normal sentence-pacing through.
        # 800ms was clipping mid-monologue: any natural thinking pause
        # (>0.8s) made VAD declare end-of-utterance, the recording stopped,
        # and the user had to double-tap again. 2000ms matches the
        # WisprFlow / Aqua range for hands-free dictation and is large
        # enough that "I'm thinking how to phrase this" doesn't get
        # interpreted as "I'm done". Still snappy enough that the
        # "actually done" experience feels responsive.
        """Hands-free mode: helper streams chunks back, we run webrtcvad
        and fire `on_auto_stop` once the user has clearly stopped speaking.

        `on_auto_stop()` is called from the reader thread, NOT the main
        thread — the caller is expected to dispatch back to the rumps
        run loop via AppHelper.callAfter if they need GUI work.
        """
        if self._armed:
            return
        # Lazy-import webrtcvad so this module still loads on dev machines
        # that haven't installed it yet (e.g. running unit tests). Falls
        # back to a plain start() if missing — better than a hard crash.
        try:
            import webrtcvad
        except ImportError:
            print("[recorder] webrtcvad not installed — falling back to plain start()",
                  flush=True)
            self.start()
            return

        self._vad = webrtcvad.Vad(self._VAD_AGGRESSIVENESS)
        self._vad_pending = bytearray()
        self._vad_silence_ms = 0
        self._vad_total_ms = 0
        self._vad_saw_speech = False
        self._vad_silence_threshold_ms = silence_threshold_ms
        self._vad_min_recording_ms = min_recording_ms
        self._vad_on_auto_stop = on_auto_stop
        self._vad_active = True

        self._armed = True
        # b'V' = ARM with streaming. Helper sends b'C' chunks until DISARM.
        self._send_cmd(b"V")

    # ── Public API (matches the legacy in-process Recorder) ────────────

    def start(self) -> None:
        if self._armed:
            return
        self._armed = True
        # Best-effort send — if the helper is mid-respawn the next start
        # cycle picks up clean state.
        self._send_cmd(b"A")

    def stop(self) -> np.ndarray:
        if not self._armed:
            return np.zeros(0, dtype=np.float32)
        self._armed = False
        self._stop_event.clear()
        with self._stop_lock:
            self._stop_audio = np.zeros(0, dtype=np.float32)
        if not self._send_cmd(b"D"):
            # Helper dead — return what we never got.
            return np.zeros(0, dtype=np.float32)
        # Wait for the helper to send the audio frame back. Bounded so a
        # silently wedged helper (which the watchdog will catch on the
        # next ping cycle) doesn't hang the caller forever — the caller
        # gets zero-length audio and the watchdog respawns the helper
        # within ~4 seconds.
        if not self._stop_event.wait(_DISARM_AUDIO_TIMEOUT_SEC):
            print(f"[recorder] stop() timed out waiting for audio frame", flush=True)
            return np.zeros(0, dtype=np.float32)
        with self._stop_lock:
            return self._stop_audio

    @property
    def is_recording(self) -> bool:
        return self._armed

    def get_levels(self, n: int = 60) -> list[float]:
        """Most recent N RMS samples. Thread-safe."""
        with self._levels_lock:
            data = list(self._levels)
        return data[-n:] if len(data) > n else data

    def shutdown(self) -> None:
        """Graceful teardown — called on app quit if anyone wires it up.
        Best effort; py2app exit is messy enough that we don't depend on this."""
        self._shutdown = True
        self._send_cmd(b"Q")
        if self._proc is not None:
            try:
                self._proc.wait(timeout=1.0)
            except Exception:
                try:
                    self._proc.kill()
                except Exception:
                    pass
