"""Post-meeting transcription.

After MeetingRecording.stop() finalises the two WAV files, this module
takes that meeting folder and produces transcript.md.

Why post-stop instead of live chunked transcription:
  - Whisper accuracy is significantly better on full utterances than on
    arbitrarily chopped 5-second chunks (it conditions on the previous
    text to disambiguate homophones, fix punctuation, choose between
    languages mid-stream).
  - Both files are already on disk by the time stop() returns, so a
    crash in the transcriber loses transcript only, never audio.
  - Live transcription would need the chunk boundaries to align with
    speech pauses (VAD) — extra moving parts for Phase 1.

If we want streaming live transcript later (Phase 2.5), the format
written here is compatible: speaker-prefixed lines with [MM:SS] markers,
appendable, so a streaming writer could just open in append mode and
keep going where this stopped.

Output format (transcript.md):

    # Meeting — 2026-06-04 04:15
    Duration: 12:34
    Captured: mic + system audio (whole display)

    ## Transcript

    [00:03] You: Привет, давайте начнём.
    [00:07] System: Hi everyone, thanks for joining.
    [00:12] You: Сегодня обсудим квартальный план…
    ...

Speakers:
  - "You" = audio_mic.wav (user's microphone)
  - "System" = audio_system.wav (everyone else, via ScreenCaptureKit)

Both streams are transcribed independently then merged by start
timestamp. Whisper sometimes returns the same chunk twice when the
two streams pick up acoustic crosstalk — the merge stage de-dupes on
(start, normalised_text) match.
"""

from __future__ import annotations

import json
import threading
import wave
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import numpy as np


def _load_wav_as_float32(path: Path) -> tuple[np.ndarray, int]:
    """Load a 16 kHz mono Int16 WAV (what meeting_recorder.py writes) as
    a float32 array in [-1.0, 1.0]. Returns (samples, sample_rate)."""
    with wave.open(str(path), "rb") as w:
        sr = w.getframerate()
        n = w.getnframes()
        nch = w.getnchannels()
        sw = w.getsampwidth()
        raw = w.readframes(n)
    if sw != 2:
        raise ValueError(f"{path.name}: expected 16-bit PCM, got {sw * 8}-bit")
    int16 = np.frombuffer(raw, dtype=np.int16)
    if nch > 1:
        # downmix any stereo file to mono (mic + system are both written mono
        # by meeting_recorder.py but defensive — Whisper expects mono).
        int16 = int16.reshape(-1, nch).mean(axis=1).astype(np.int16)
    return int16.astype(np.float32) / 32768.0, sr


def _format_mmss(seconds: float) -> str:
    s = int(round(seconds))
    return f"{s // 60:02d}:{s - (s // 60) * 60:02d}"


def _segments_with_speaker(segments: list[dict[str, Any]],
                           speaker: str) -> list[dict[str, Any]]:
    """Whisper returns per-segment dicts with 'start', 'end', 'text'. Tag
    each with the speaker label and normalise the text (strip whitespace,
    drop empties) so the merger has clean inputs."""
    out: list[dict[str, Any]] = []
    for seg in segments or []:
        text = (seg.get("text") or "").strip()
        if not text:
            continue
        out.append({
            "start": float(seg.get("start", 0.0)),
            "end": float(seg.get("end", 0.0)),
            "text": text,
            "speaker": speaker,
        })
    return out


def _merge_timelines(mic_segs: list[dict[str, Any]],
                     sys_segs: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Interleave two segment lists by start time. De-dupes near-identical
    consecutive segments (same speaker tagged on both streams within 1 s
    and same normalised text) — that's acoustic crosstalk, not a real
    second utterance."""
    merged = sorted(mic_segs + sys_segs, key=lambda s: s["start"])
    deduped: list[dict[str, Any]] = []
    for seg in merged:
        norm = seg["text"].lower().strip()
        if deduped:
            last = deduped[-1]
            if (abs(seg["start"] - last["start"]) < 1.0
                    and norm == last["text"].lower().strip()):
                # Same utterance picked up on both channels — keep whichever
                # came first.
                continue
        deduped.append(seg)
    return deduped


def transcribe_meeting(meeting_dir: Path, transcriber: Any,
                       *, language: Optional[str] = None) -> Optional[Path]:
    """Run the transcriber over both WAV files in `meeting_dir` and write
    transcript.md alongside them. Returns the transcript path on success,
    None if the directory is missing required files.

    `transcriber` is any object with a `.transcribe(audio_f32, sample_rate)`
    method returning {"text", "language", "segments"} — i.e. the existing
    Lirelle make_transcriber() output. Reusing the warmed-up instance owned
    by LirelleApp avoids the ~3 s MLX model reload per meeting.

    `language` is currently informational only — the transcriber instance
    is already configured (typically auto-detect)."""
    mic_path = meeting_dir / "audio_mic.wav"
    sys_path = meeting_dir / "audio_system.wav"
    meta_path = meeting_dir / "meta.json"
    if not mic_path.exists() or not sys_path.exists():
        print(f"[meeting_transcribe] missing wav files in {meeting_dir}")
        return None

    # CR (2026-06-23) — bail with a readable status if the local Whisper
    # model isn't downloaded yet. Without this guard the transcriber falls
    # straight into mlx_whisper, which raises a deep huggingface_hub error
    # the user never sees (it's swallowed by the per-stream try/except
    # below) — meeting "completes" with an empty transcript and the
    # Meetings UI shows nothing about why. Write a status to meta.json
    # so meetings-bind.js can surface a "Download offline model" CTA on
    # the meeting card.
    try:
        if hasattr(transcriber, "is_model_ready") and not transcriber.is_model_ready():
            print(f"[meeting_transcribe] model not downloaded → aborting "
                  f"transcribe for {meeting_dir.name}; meeting card will "
                  f"prompt user to fetch it", flush=True)
            try:
                import json as _json
                meta = {}
                if meta_path.exists():
                    meta = _json.loads(meta_path.read_text(encoding="utf-8"))
                meta["status"] = "awaiting_local_model"
                meta["status_message"] = (
                    "Meeting recorded. The offline Whisper model isn't "
                    "downloaded yet — open Settings → Offline Model to "
                    "fetch it (~1.5 GB), then click Retry on this card."
                )
                meta_path.write_text(
                    _json.dumps(meta, ensure_ascii=False, indent=2),
                    encoding="utf-8",
                )
            except Exception as e:  # noqa: BLE001
                print(f"[meeting_transcribe] couldn't write meta: {e}",
                      flush=True)
            return None
    except Exception as e:  # noqa: BLE001
        # is_model_ready itself blew up — log and proceed; the deeper
        # try/excepts will catch any actual transcribe failure.
        print(f"[meeting_transcribe] model-ready probe raised: {e}",
              flush=True)

    print(f"[meeting_transcribe] starting → {meeting_dir.name}")

    mic_audio, mic_sr = _load_wav_as_float32(mic_path)
    sys_audio, sys_sr = _load_wav_as_float32(sys_path)

    mic_segs: list[dict[str, Any]] = []
    sys_segs: list[dict[str, Any]] = []

    # mic pass — only if there's actual content (skip silent rooms).
    if mic_audio.size > 0 and float(np.abs(mic_audio).max()) > 0.005:
        try:
            mic_result = transcriber.transcribe(mic_audio, sample_rate=mic_sr)
            mic_segs = _segments_with_speaker(
                mic_result.get("segments", []), speaker="You",
            )
            print(f"[meeting_transcribe] mic: {len(mic_segs)} segments")
        except Exception as e:
            print(f"[meeting_transcribe] mic transcribe failed: {e}")

    # system audio pass.
    if sys_audio.size > 0 and float(np.abs(sys_audio).max()) > 0.005:
        try:
            sys_result = transcriber.transcribe(sys_audio, sample_rate=sys_sr)
            sys_segs = _segments_with_speaker(
                sys_result.get("segments", []), speaker="System",
            )
            print(f"[meeting_transcribe] system: {len(sys_segs)} segments")
        except Exception as e:
            print(f"[meeting_transcribe] system transcribe failed: {e}")

    timeline = _merge_timelines(mic_segs, sys_segs)

    # Compose markdown.
    duration_s = 0.0
    started = "—"
    try:
        meta = json.loads(meta_path.read_text(encoding="utf-8"))
        duration_s = float(meta.get("duration_seconds", 0.0))
        started = meta.get("started_at", "—")
    except Exception:
        pass

    lines: list[str] = []
    lines.append(f"# Meeting — {started}")
    lines.append(f"Duration: {_format_mmss(duration_s)}")
    lines.append("Captured: mic (You) + system audio (System)")
    lines.append("")
    lines.append("## Transcript")
    lines.append("")
    if not timeline:
        lines.append("_(no speech detected on either channel)_")
    else:
        for seg in timeline:
            lines.append(
                f"[{_format_mmss(seg['start'])}] {seg['speaker']}: {seg['text']}"
            )

    transcript_path = meeting_dir / "transcript.md"
    transcript_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"[meeting_transcribe] wrote → {transcript_path}")

    # Bump meta.json status so the bank UI can render a "transcribed" badge.
    try:
        meta = json.loads(meta_path.read_text(encoding="utf-8"))
        meta["status"] = "transcribed"
        meta["transcribed_at"] = datetime.now().isoformat(timespec="seconds")
        meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
    except Exception as e:
        print(f"[meeting_transcribe] meta update failed: {e}")

    return transcript_path


def transcribe_meeting_async(meeting_dir: Path, transcriber: Any,
                             *, on_done: Optional[Any] = None,
                             language: Optional[str] = None) -> threading.Thread:
    """Run transcribe_meeting() on a background daemon thread. `on_done`
    is called with the transcript path (or None) when finished — wire it
    to AppHelper.callAfter(<bank rebuild>) so the UI refreshes."""
    def _worker():
        try:
            path = transcribe_meeting(meeting_dir, transcriber, language=language)
        except Exception as e:
            print(f"[meeting_transcribe] worker crashed: {e}")
            path = None
        if on_done is not None:
            try:
                on_done(path)
            except Exception as e:
                print(f"[meeting_transcribe] on_done callback failed: {e}")

    t = threading.Thread(target=_worker, daemon=True)
    t.start()
    return t
