"""Meeting recorder: orchestrates dual capture (mic + system audio) → two
WAV files on disk, plus a meta.json with timing.

Architecture matches the user's spec (2026-06-04):

  - Explicit Start / Stop buttons, NEVER a hotkey (during a call the user
    presses lots of keys; an accidental Lirelle trigger is unrecoverable).
  - Always whole-system audio capture (no SCK picker UI) — nobody listens
    to music during a meeting; simpler UX wins.
  - Chunked transcription happens in Phase 2 (segments_transcriber) — this
    module just persists raw audio so a crash never loses data.
  - All artifacts live under
    ~/Library/Application Support/Lumo/meetings/{YYYY-MM-DD-HHMM-{slug}}/
    so reinstall / Sparkle / `rm -rf /Applications/Lirelle.app` can't touch
    them.

Files produced per meeting:

    audio_mic.wav      — user's microphone, 16 kHz mono Int16
    audio_system.wav   — everything the OS was playing back, 16 kHz mono Int16
                         (the "other side" of the conversation: Zoom / Meet /
                         Slack / whatever)
    meta.json          — { started_at, stopped_at, duration_seconds, status }
    transcript.md      — written by Phase 2 (chunked transcription)
    summary.md         — written by Phase 3 (LLM summary)

Both audio files share the same 16 kHz mono Int16 format so Phase 2 can
feed them through a single make_transcriber() pipeline (MLX on Apple
Silicon, whisper.cpp on Intel — picked automatically).
"""

from __future__ import annotations

import collections
import json
import os
import struct
import subprocess
import sys
import threading
import time
import wave
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import numpy as np

from .sysaudio_proxy import SysAudioProxy


SAMPLE_RATE = 16000  # both mic + system streams normalised to this rate


class MeetingStartError(RuntimeError):
    """Raised when MeetingRecording can't get both helpers (mic + system
    audio) running. Most common cause is the user not having granted
    Screen Recording permission yet, or having granted it but not yet
    quit + relaunched Lirelle so macOS picks it up."""


def meetings_root() -> Path:
    """Where meetings live. Same Application Support subtree as transcripts,
    so the data-persistence guarantees in src/store.py apply here too."""
    p = Path.home() / "Library" / "Application Support" / "Lumo" / "meetings"
    p.mkdir(parents=True, exist_ok=True)
    return p


def _audio_helper_argv() -> list[str]:
    """Same logic as src/recorder.py:_audio_helper_argv — pick the right
    re-exec path so the mic helper subprocess spawns from the right binary
    in both dev (lumo_main.py via venv python) and frozen .app (bootloader)."""
    here = Path(__file__).resolve().parent.parent
    if getattr(sys, "frozen", False):
        bootloader = here.parent / "MacOS" / "Lumo"
        if bootloader.exists():
            return [str(bootloader), "--audio-helper"]
    lumo_main = here / "lumo_main.py"
    return [sys.executable, str(lumo_main), "--audio-helper"]


class MeetingRecording:
    """One active meeting recording. Don't instantiate directly — use
    MeetingRecorder.start() which returns a live instance."""

    def __init__(self, slug: str = "meeting",
                 transcriber: Optional[Any] = None):
        # Fetch the meeting capture token BEFORE we set up any files or
        # subprocesses — if the user isn't on Plus / not logged in / can't
        # reach the server, fail cleanly with a clear MeetingStartError
        # and zero side effects on disk. Tokens are short-lived (server
        # TTL = 4h) so each recording requests a fresh one.
        from .licensing import request_meeting_token
        ok, token_or_reason = request_meeting_token()
        if not ok:
            raise MeetingStartError(token_or_reason)
        self._token = token_or_reason

        ts = datetime.now()
        dir_name = ts.strftime("%Y-%m-%d-%H%M") + f"-{slug}"
        self.dir = meetings_root() / dir_name
        self.dir.mkdir(parents=True, exist_ok=True)
        self.mic_path = self.dir / "audio_mic.wav"
        self.sys_path = self.dir / "audio_system.wav"
        self.meta_path = self.dir / "meta.json"
        self.started_at = ts
        self._transcriber = transcriber

        self._mic_wav = wave.open(str(self.mic_path), "wb")
        self._mic_wav.setnchannels(1)
        self._mic_wav.setsampwidth(2)
        self._mic_wav.setframerate(SAMPLE_RATE)

        self._sys_wav = wave.open(str(self.sys_path), "wb")
        self._sys_wav.setnchannels(1)
        self._sys_wav.setsampwidth(2)
        self._sys_wav.setframerate(SAMPLE_RATE)

        self._wav_lock = threading.Lock()
        self._mic_samples_written = 0
        self._sys_samples_written = 0
        self._stopped = False

        # Live audio levels for the floating capsule. Each drain thread pushes
        # one RMS reading per incoming chunk (~20–50 ms apart). We keep the
        # last LEVEL_BUFFER readings and the overlay's CapsuleView reads from
        # here via a provider callback — same pattern as the dictation
        # overlay reads from recorder.py.
        self._levels_lock = threading.Lock()
        self._levels: collections.deque[float] = collections.deque(maxlen=64)

        # ── Live transcription pipeline (Phase 3a) ──────────────────────
        # When a transcriber is provided, audio also flows through a pair
        # of VAD chunkers (one per stream) that emit ~5-60s natural-pause
        # chunks. Each chunk is queued for the LiveTranscribeRunner which
        # transcribes serially and rewrites transcript.md atomically every
        # ~3s. Crash-safety: everything transcribed up to the most recent
        # write is on disk; worst case loss is ~30-60s of audio that was
        # buffered in a chunker or in-flight in the transcriber.
        self._mic_chunker = None
        self._sys_chunker = None
        self._live = None
        if self._transcriber is not None:
            # Best-effort live transcription setup. If anything fails
            # here (webrtcvad import, VAD init, runner threads), we fall
            # back to recording-only mode — audio still captures cleanly
            # and the legacy post-stop retro-transcribe path picks it up.
            try:
                from .meeting_chunker import VADChunker
                from .meeting_live_transcribe import LiveTranscribeRunner
                self._live = LiveTranscribeRunner(
                    meeting_dir=self.dir,
                    transcriber=self._transcriber,
                    started_at=self.started_at,
                )
                # Bind callbacks via default args so the speaker label
                # binds eagerly, not via late-binding `self`.
                self._mic_chunker = VADChunker(
                    on_chunk=lambda offs, audio: self._live.submit_chunk(
                        "You", offs, audio,
                    ),
                )
                self._sys_chunker = VADChunker(
                    on_chunk=lambda offs, audio: self._live.submit_chunk(
                        "System", offs, audio,
                    ),
                )
            except Exception as e:
                print(f"[meeting] live transcription unavailable "
                      f"({e!r}) — recording only, will retro-transcribe at stop",
                      flush=True)
                # Clean up partial state.
                if self._live is not None:
                    try:
                        self._live.stop()
                    except Exception:
                        pass
                self._live = None
                self._mic_chunker = None
                self._sys_chunker = None
        else:
            print("[meeting] no transcriber provided — recording only, "
                  "transcript.md will not be generated live", flush=True)

        self._write_meta(status="recording", duration_seconds=0)

        # ── Subprocess #1: mic helper (reuse existing src/audio_helper.py
        #    in streaming mode — b'V' command pushes chunks via b'C' frames).
        helper_log = os.path.expanduser("~/Library/Logs/Lirelle_audio_helper.log")
        try:
            stderr_fh = open(helper_log, "a", buffering=1)
        except Exception:
            stderr_fh = subprocess.DEVNULL
        self._mic_proc = subprocess.Popen(
            _audio_helper_argv(),
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=stderr_fh,
            bufsize=0,
        )

        # ── Subprocess #2: system audio (Swift ScreenCaptureKit helper).
        # The proxy ships the token to the helper before any ARM frame so
        # the helper can verify Ed25519 signature + machine_id + exp.
        self._sys_proxy = SysAudioProxy(token=self._token)

        # Drain threads — one per stream, each writing to its WAV file.
        self._mic_reader = threading.Thread(target=self._drain_mic, daemon=True)
        self._sys_drainer = threading.Thread(target=self._drain_sys, daemon=True)
        self._mic_reader.start()
        self._sys_drainer.start()

        # Wait for sysaudio helper to be ready before arming mic, so both
        # streams start as close to simultaneously as possible. If the
        # helper times out or errors (e.g. TCC declined), tear everything
        # down and bubble a MeetingStartError up — the bank UI catches it
        # and shows the actual error message so the user knows what to do.
        if not self._sys_proxy.wait_ready(timeout=5.0):
            reason = self._sys_proxy.error or "screen-recording permission denied or timed out"
            print(f"[meeting] sysaudio helper failed to start: {reason}", flush=True)
            # Best-effort cleanup so we don't leave half-open WAVs or zombie procs.
            try:
                self._mic_proc.stdin.write(b"Q")
                self._mic_proc.stdin.flush()
                self._mic_proc.wait(timeout=1.0)
            except Exception:
                try:
                    self._mic_proc.kill()
                except Exception:
                    pass
            try:
                self._sys_proxy.shutdown()
            except Exception:
                pass
            # Tear down the live runner if we constructed one. There's
            # nothing in the queue yet but the worker + writer threads
            # are alive and would leak.
            if self._live is not None:
                try:
                    self._live.stop()
                except Exception:
                    pass
            try:
                self._mic_wav.close()
                self._sys_wav.close()
            except Exception:
                pass
            self._write_meta(status="failed", duration_seconds=0)
            raise MeetingStartError(reason)

        # ARM both:
        try:
            self._mic_proc.stdin.write(b"V")  # ARM with streaming
            self._mic_proc.stdin.flush()
        except (BrokenPipeError, OSError):
            pass
        self._sys_proxy.arm()
        print(f"[meeting] recording started → {self.dir}", flush=True)

    # ── Mic helper protocol (matches src/audio_helper.py frames) ───────

    def _read_exact_mic(self, n: int) -> Optional[bytes]:
        out = self._mic_proc.stdout
        if out is None:
            return None
        buf = bytearray()
        while len(buf) < n:
            chunk = out.read(n - len(buf))
            if not chunk:
                return None
            buf += chunk
        return bytes(buf)

    def _drain_mic(self) -> None:
        """Read frames from the mic helper. The audio_helper.py protocol
        gives us b'C' chunks (float32 mono 16 kHz) when armed with b'V'."""
        while True:
            hdr = self._read_exact_mic(5)
            if hdr is None:
                break
            ftype = hdr[0:1]
            (plen,) = struct.unpack("<I", hdr[1:5])
            payload = self._read_exact_mic(plen) if plen > 0 else b""
            if plen > 0 and (payload is None or len(payload) < plen):
                break
            if ftype == b"C":
                # mic chunks are float32 mono at 16 kHz already
                samples_f32 = np.frombuffer(payload, dtype=np.float32)
                if samples_f32.size == 0:
                    continue
                int16 = (np.clip(samples_f32, -1.0, 1.0) * 32767.0).astype(np.int16)
                with self._wav_lock:
                    if not self._stopped:
                        self._mic_wav.writeframes(int16.tobytes())
                        self._mic_samples_written += int16.size
                # Push RMS level for the floating capsule. Range [0..1] —
                # speech around 0.05–0.3 with EMA smoothing in the view.
                rms = float(np.sqrt(np.mean(samples_f32 * samples_f32)))
                self._push_level(rms)
                # Feed the live-transcription chunker (Phase 3a). Fires
                # synchronously on the drain thread — cheap (VAD frame
                # eval + occasional callback that just enqueues).
                if self._mic_chunker is not None and not self._stopped:
                    try:
                        self._mic_chunker.feed(int16)
                    except Exception as e:
                        print(f"[meeting] mic chunker feed failed: {e}",
                              flush=True)
            # ignore L / O / E — we don't need levels here, and errors get
            # logged to the helper's stderr log file

    def _drain_sys(self) -> None:
        """Pull 16 kHz Int16 chunks out of the sysaudio proxy."""
        while not self._stopped:
            data = self._sys_proxy.poll_int16(max_samples=4000)
            if not data:
                time.sleep(0.02)
                continue
            with self._wav_lock:
                if not self._stopped:
                    self._sys_wav.writeframes(data)
                    self._sys_samples_written += len(data) // 2
            # RMS level for the floating capsule. Int16 → float32 first.
            int16 = np.frombuffer(data, dtype=np.int16)
            if int16.size > 0:
                f32 = int16.astype(np.float32) / 32768.0
                rms = float(np.sqrt(np.mean(f32 * f32)))
                self._push_level(rms)
                # Feed the live-transcription chunker (Phase 3a).
                if self._sys_chunker is not None and not self._stopped:
                    try:
                        self._sys_chunker.feed(int16)
                    except Exception as e:
                        print(f"[meeting] sys chunker feed failed: {e}",
                              flush=True)

    def _push_level(self, rms: float) -> None:
        """Append one RMS reading. Both drains call this; the capsule
        reads the max over the most recent N readings so either side of
        the conversation lights the indicator."""
        with self._levels_lock:
            self._levels.append(rms)

    def recent_levels(self, n: int = 4) -> list[float]:
        with self._levels_lock:
            if not self._levels:
                return []
            buf = list(self._levels)
        return buf[-n:]

    # ── public API ─────────────────────────────────────────────────────

    @property
    def duration_seconds(self) -> float:
        return (datetime.now() - self.started_at).total_seconds()

    def stop(self) -> Path:
        """Stop recording, drain live transcription queue, finalise WAVs,
        write final meta.json. Returns the meeting's directory.

        Order of operations matters:
          1. Stop subprocess audio sources (so no new samples arrive).
          2. Flush both VAD chunkers — any partial in-flight chunk gets
             emitted as a final chunk into the live queue.
          3. Block until the live queue drains (transcript.md gets all
             remaining segments).
          4. Close WAV files.
          5. Write final meta.json with transcribed status.
        """
        if self._stopped:
            return self.dir
        self._stopped = True
        duration_at_stop = self.duration_seconds

        # ── 1. Stop the audio sources ────────────────────────────────
        try:
            self._mic_proc.stdin.write(b"D")
            self._mic_proc.stdin.flush()
            time.sleep(0.1)  # give the mic helper a beat to flush its drain
            self._mic_proc.stdin.write(b"Q")
            self._mic_proc.stdin.flush()
        except (BrokenPipeError, OSError):
            pass
        self._sys_proxy.disarm()
        time.sleep(0.1)
        self._sys_proxy.shutdown()

        try:
            self._mic_proc.wait(timeout=2.0)
        except subprocess.TimeoutExpired:
            self._mic_proc.kill()

        # ── 2. Flush VAD chunkers ────────────────────────────────────
        # Any chunk currently being accumulated emits now (or is dropped
        # if too short to be useful). After this, no more chunks enter
        # the live queue.
        if self._mic_chunker is not None:
            try:
                self._mic_chunker.flush()
            except Exception as e:
                print(f"[meeting] mic chunker flush failed: {e}", flush=True)
        if self._sys_chunker is not None:
            try:
                self._sys_chunker.flush()
            except Exception as e:
                print(f"[meeting] sys chunker flush failed: {e}", flush=True)

        # ── 3. Drain the live transcription queue ────────────────────
        # This is where stop() may block for tens of seconds on the
        # tail end of a long meeting — the worker thread is still
        # transcribing the last few chunks. Total cap = 5 minutes (see
        # DEFAULT_FLUSH_TIMEOUT_S in meeting_live_transcribe).
        meta_status = "transcribed"
        if self._live is not None:
            self._write_meta(status="finalising",
                             duration_seconds=duration_at_stop)
            print(f"[meeting] draining live transcription queue "
                  f"({self._live.queue_depth} items)…", flush=True)
            drained = self._live.flush_and_wait()
            self._live.stop()
            if not drained:
                # Some chunks didn't finish — UI should still show what
                # we have. Mark transcript as partial.
                meta_status = "transcribed_partial"
                print("[meeting] live drain timed out; transcript may be "
                      "incomplete", flush=True)
        else:
            # No transcriber was wired — recording-only mode. Mark for
            # the post-stop retro-transcribe path (kept around for
            # exactly this case).
            meta_status = "ready_for_transcription"

        # ── 4. Finalise WAV headers ──────────────────────────────────
        with self._wav_lock:
            try:
                self._mic_wav.close()
            except Exception:
                pass
            try:
                self._sys_wav.close()
            except Exception:
                pass

        # ── 5. Final meta.json ───────────────────────────────────────
        self._write_meta(status=meta_status,
                         duration_seconds=duration_at_stop,
                         chunks_processed=(
                             self._live.chunks_processed if self._live else 0
                         ))
        print(f"[meeting] stopped — mic={self._mic_samples_written} samples, "
              f"sys={self._sys_samples_written} samples, "
              f"chunks={self._live.chunks_processed if self._live else 0}, "
              f"status={meta_status}, "
              f"dir={self.dir}", flush=True)
        return self.dir

    def _write_meta(self, *, status: str, duration_seconds: float,
                    chunks_processed: int = 0) -> None:
        meta = {
            "started_at": self.started_at.isoformat(timespec="seconds"),
            "stopped_at": (
                datetime.now().isoformat(timespec="seconds")
                if status != "recording" else None
            ),
            "duration_seconds": duration_seconds,
            "status": status,
            "files": {
                "mic": "audio_mic.wav",
                "system": "audio_system.wav",
            },
            "chunks_processed": chunks_processed,
        }
        if status == "transcribed" or status == "transcribed_partial":
            meta["transcribed_at"] = datetime.now().isoformat(timespec="seconds")
        try:
            self.meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
        except OSError as e:
            print(f"[meeting] meta write failed: {e}", flush=True)


# CR #12 — startup recovery for meetings left in status="recording"
# because the app crashed / was force-quit / Mac rebooted mid-recording.
# Without this they'd hang as "RECORDING" badges in the UI forever,
# the WAVs would stay with broken headers (wave's RIFF/data sizes are
# written only on stop()), and there's no manual UI path to revive them.


def _fix_wav_header_in_place(p: Path) -> bool:
    """Rewrite RIFF + data chunk sizes in a WAV file from its actual on-
    disk length. Python's `wave` module writes zeros there at open()
    and patches them on close(); an aborted recording leaves them at
    zero, which makes most players (and mlx_whisper) refuse the file.
    Standard PCM layout: 12-byte RIFF, then a 'fmt ' chunk we keep, then
    a 'data' chunk whose 4-byte size we recompute."""
    try:
        size = p.stat().st_size
        if size < 44:                       # smaller than a valid header
            return False
        with p.open("r+b") as f:
            head = f.read(12)
            if head[:4] != b"RIFF" or head[8:12] != b"WAVE":
                return False
            # RIFF chunk size = file size - 8.
            f.seek(4)
            f.write((size - 8).to_bytes(4, "little"))
            # Scan to the 'data' chunk header (after 'fmt ' chunk).
            f.seek(12)
            while True:
                ch = f.read(8)
                if len(ch) < 8:
                    return False
                tag, csize = ch[:4], int.from_bytes(ch[4:8], "little")
                if tag == b"data":
                    # Patch the data chunk's size to the remaining bytes.
                    data_pos = f.tell()
                    f.seek(data_pos - 4)
                    f.write((size - data_pos).to_bytes(4, "little"))
                    return True
                f.seek(csize, 1)            # skip non-data chunk
    except Exception as e:
        print(f"[meeting] _fix_wav_header_in_place({p.name}) failed: {e}",
              flush=True)
        return False


def recover_orphan_meetings(transcriber_provider=None) -> int:
    """Find meetings with meta.status=='recording' that aren't owned by
    any live recorder, fix their WAV headers, mark them
    ready_for_transcription, and (if a transcriber is available) kick
    the retro-transcribe + summary pipeline for each. Returns the count.

    Called once at LirelleApp startup. CR #12.
    """
    import json as _json
    from datetime import datetime as _dt
    root = meetings_root()
    if not root.exists():
        return 0
    recovered = 0
    for d in sorted(root.iterdir(), reverse=True):
        if not d.is_dir():
            continue
        meta_path = d / "meta.json"
        try:
            meta = _json.loads(meta_path.read_text(encoding="utf-8"))
        except Exception:
            continue
        if meta.get("status") != "recording":
            continue
        # Best-effort header repair on both WAVs.
        for wav_name in ("audio_mic.wav", "audio_system.wav"):
            wav = d / wav_name
            if wav.exists():
                _fix_wav_header_in_place(wav)
        # Stamp a finished_at and demote status so the retro-transcribe
        # path takes over.
        meta["status"] = "ready_for_transcription"
        meta["stopped_at"] = meta.get("stopped_at") or _dt.now().isoformat(
            timespec="seconds")
        meta["recovered_at"] = _dt.now().isoformat(timespec="seconds")
        try:
            meta_path.write_text(
                _json.dumps(meta, ensure_ascii=False, indent=2),
                encoding="utf-8")
        except Exception as e:
            print(f"[meeting] recover_orphan: meta rewrite for "
                  f"{d.name} failed: {e}", flush=True)
            continue
        print(f"[meeting] recovered orphan {d.name} "
              f"(was status='recording')", flush=True)
        recovered += 1
        if transcriber_provider is not None:
            try:
                tr = transcriber_provider()
                if tr is not None:
                    from .meeting_transcribe import transcribe_meeting_async
                    transcribe_meeting_async(d, tr, on_done=None)
            except Exception as e:
                print(f"[meeting] retro-transcribe kickoff for {d.name} "
                      f"failed: {e}", flush=True)
    return recovered


class MeetingRecorder:
    """Global singleton-ish controller. Only one recording at a time.

    Owns the optional floating capsule overlay (top-right of screen) so a
    user mid-call can see the recording is live and stop it without bringing
    the Lirelle bank window forward."""

    def __init__(self) -> None:
        self._current: Optional[MeetingRecording] = None
        self._lock = threading.Lock()
        self._overlay = None  # MeetingOverlay, lazy-imported
        # Hooks the bank UI wires up so transcription kicks off as soon as
        # a recording stops — via either the bank's Stop button OR the
        # floating capsule's stop button. Both paths converge here.
        self.transcriber_provider = None  # Callable[[], Transcriber|None]
        self.on_transcribed = None        # Callable[[Path|None], None]
        # CR (2026-06-23) — `on_stopped` fires immediately after stop()
        # finalises the WAVs, BEFORE transcription kicks off. Bank uses
        # it to push `recording-stopped` to the web UI from any stop
        # path (capsule X button, in-app Stop button, programmatic).
        # Without this, capsule-initiated stops left bank's
        # `data-recording="1"` set forever — so the user's NEXT click
        # in bank Meetings tried to stopMeeting (no-op, recorder
        # already idle), the start flow never ran, and a second
        # meeting was impossible until reopening the app.
        self.on_stopped = None            # Callable[[Path|None], None]

    def is_recording(self) -> bool:
        with self._lock:
            return self._current is not None

    def start(self, slug: str = "meeting") -> MeetingRecording:
        with self._lock:
            if self._current is not None:
                return self._current
            # Resolve the transcriber NOW (before construction) so live
            # transcription wires up at meeting start. None is fine —
            # MeetingRecording falls back to recording-only mode and the
            # legacy retro_transcribe path picks it up later.
            transcriber = None
            if self.transcriber_provider is not None:
                try:
                    transcriber = self.transcriber_provider()
                except Exception as e:
                    print(f"[meeting] transcriber_provider raised: {e}",
                          flush=True)
            self._current = MeetingRecording(
                slug=slug, transcriber=transcriber,
            )
        # Show the floating capsule on the main thread. Lazy-import so the
        # rest of the meeting_recorder API works in headless / smoke contexts.
        try:
            if self._overlay is None:
                from .meeting_overlay import MeetingOverlay
                self._overlay = MeetingOverlay()
            self._overlay.show(
                on_stop=self.stop,
                levels_provider=self.recent_levels,
            )
        except Exception as e:
            print(f"[meeting] overlay show failed (continuing): {e}", flush=True)
        return self._current

    def stop(self) -> Optional[Path]:
        with self._lock:
            if self._current is None:
                return None
            cur = self._current
            self._current = None
        # Hide overlay before doing the heavy WAV-finalisation work so the
        # user gets immediate visual feedback that the stop took effect.
        if self._overlay is not None:
            try:
                self._overlay.hide()
            except Exception:
                pass
        d = cur.stop()
        # Tell any listener (typically the bank web UI) we stopped. Fire
        # BEFORE the transcribe kickoff below so the UI can refresh its
        # recording-state flag immediately; transcription still happens
        # on a worker thread, and the eventual `on_transcribed` callback
        # will fire when transcript.md is on disk.
        if self.on_stopped is not None:
            try:
                self.on_stopped(d)
            except Exception as e:  # noqa: BLE001
                print(f"[meeting] on_stopped raised: {e}", flush=True)
        # Phase 3a happy path: live transcription happened inside cur.stop()
        # — transcript.md is complete (or marked partial on timeout).
        # Phase 3a fallback: if live setup failed (e.g. webrtcvad missing a
        # transitive on this build), meta.status will be "ready_for_transcription"
        # and we kick off the legacy retro-transcribe on a bg thread so the
        # user still gets a transcript at the cost of a longer wait.
        if d is not None:
            needs_retro = False
            try:
                import json as _json
                meta = _json.loads((d / "meta.json").read_text(encoding="utf-8"))
                needs_retro = meta.get("status") == "ready_for_transcription"
            except Exception:
                pass
            if needs_retro and self.transcriber_provider is not None:
                try:
                    tr = self.transcriber_provider()
                    if tr is not None:
                        from .meeting_transcribe import transcribe_meeting_async
                        print("[meeting] live unavailable — retro-transcribing in background",
                              flush=True)
                        transcribe_meeting_async(
                            d, tr, on_done=self.on_transcribed,
                        )
                except Exception as e:
                    print(f"[meeting] retro-transcribe kickoff failed: {e}",
                          flush=True)
            elif self.on_transcribed is not None:
                try:
                    self.on_transcribed(d)
                except Exception as e:
                    print(f"[meeting] on_transcribed callback failed: {e}",
                          flush=True)
            # Phase 3c: kick off LLM summary on a background thread.
            # Server-side: POST /meetings/summarize → Claude Sonnet 4.6
            # via OpenRouter → markdown summary in the user's Notion-style
            # format. Never blocks stop(); summary.md just shows up next
            # to transcript.md when it's ready (typically 30-90 s after
            # this call). Failures are logged but non-fatal — the user
            # still has the transcript.
            try:
                from .meeting_summarize import summarize_meeting_async
                summarize_meeting_async(
                    d,
                    on_done=self.on_transcribed,  # same refresh callback
                )
            except Exception as e:
                print(f"[meeting] summary kickoff failed: {e}",
                      flush=True)
        return d

    def current_duration(self) -> float:
        with self._lock:
            if self._current is None:
                return 0.0
            return self._current.duration_seconds

    def recent_levels(self, n: int = 4) -> list[float]:
        """Provider for the floating capsule's voice-reactive stripe."""
        with self._lock:
            if self._current is None:
                return []
            return self._current.recent_levels(n)
