"""
MeetingRecorderService - record system audio (WASAPI loopback) plus the
microphone to disk and turn the mix into a timestamped Markdown transcript.

Works for any call/meeting app (Zoom, Meet, Teams, WhatsApp, browser, ...)
because it taps the audio endpoint, not the application. Long recordings
stream straight to disk (16 kHz mono WAV ~ 115 MB/h) — no RAM growth and no
dictation-style time limit.
"""
from __future__ import annotations

import asyncio
import shutil
import threading
import wave
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Callable

import numpy as np
from loguru import logger

from .base_service import BaseService
from .i18n import t

if TYPE_CHECKING:
    from bridge.signals import AppSignals

    from .llm_service import LLMService
    from .settings_service import SettingsService
    from .transcription_service import TranscriptionService

SAMPLE_RATE = 16000
CHUNK_FRAMES = 4096                    # ~0.26 s per blocking read
MAX_MEETING_SECONDS = 4 * 3600         # hard safety stop
MIN_FREE_DISK_BYTES = 2 * 1024**3      # auto-stop below 2 GB free
DISK_CHECK_EVERY_S = 60

# Not translated: the model is instructed to answer in the transcript's own
# language, so DE meetings get a DE summary automatically.
SUMMARY_PROMPT = (
    "You are given the transcript of a meeting or call. Write a concise "
    "summary with these sections: a 2-4 sentence overview, the key points "
    "discussed, decisions made, and action items (with owners if mentioned). "
    "Use Markdown bullet lists. IMPORTANT: respond in the same language as "
    "the transcript. Output ONLY the summary."
)


def format_hms(seconds: float) -> str:
    """65 -> '00:01:05' (always hh:mm:ss so timestamps line up)."""
    s = max(0, int(seconds))
    return f"{s // 3600:02d}:{(s % 3600) // 60:02d}:{s % 60:02d}"


class WavTrackWriter:
    """Incrementally writes float32 [-1, 1] chunks as 16-bit mono WAV.

    Data goes to '<name>.part' and is renamed to the final path on close, so
    a finished file is always a valid WAV. After a crash the .part file still
    holds readable PCM (44-byte header offset) for manual recovery.
    """

    def __init__(self, final_path: Path) -> None:
        self.final_path = final_path
        self._part = final_path.with_name(final_path.name + ".part")
        self._wf = wave.open(str(self._part), "wb")
        self._wf.setnchannels(1)
        self._wf.setsampwidth(2)
        self._wf.setframerate(SAMPLE_RATE)
        self.frames = 0
        self.peak = 0.0  # loudest |sample| seen — detects silent-only tracks
        self._closed = False

    def write(self, chunk: np.ndarray) -> None:
        data = np.asarray(chunk, dtype=np.float32).reshape(-1)
        if len(data):
            self.peak = max(self.peak, float(np.max(np.abs(data))))
        pcm = np.clip(data * 32767.0, -32768, 32767).astype("<i2")
        self._wf.writeframes(pcm.tobytes())
        self.frames += len(pcm)

    def close(self) -> Path:
        # Idempotent: the capture thread closes in its finally block AND the
        # service may close again during error cleanup.
        if not self._closed:
            self._closed = True
            self._wf.close()
            self._part.replace(self.final_path)
        return self.final_path


def mix_wav_files(a: Path, b: Path, out: Path, chunk_frames: int = 1 << 18) -> None:
    """Mix two 16-bit mono WAVs sample-wise (sum + clip) into `out`.

    Streams in chunks so hour-long recordings never load into RAM. The longer
    file defines the output length; the shorter one is zero-padded.
    """
    with wave.open(str(a), "rb") as wa, wave.open(str(b), "rb") as wb, \
            wave.open(str(out), "wb") as wo:
        wo.setnchannels(1)
        wo.setsampwidth(2)
        wo.setframerate(wa.getframerate())
        remaining = max(wa.getnframes(), wb.getnframes())
        while remaining > 0:
            n = min(chunk_frames, remaining)
            ca = np.frombuffer(wa.readframes(n), dtype="<i2").astype(np.int32)
            cb = np.frombuffer(wb.readframes(n), dtype="<i2").astype(np.int32)
            ln = max(len(ca), len(cb), 1)
            mixed = np.zeros(ln, dtype=np.int32)
            mixed[: len(ca)] += ca
            mixed[: len(cb)] += cb
            wo.writeframes(np.clip(mixed, -32768, 32767).astype("<i2").tobytes())
            remaining -= n


def build_meeting_markdown(
    started_at: datetime,
    duration_s: float,
    segments: list[dict],
    summary: str | None = None,
    source: str = "",
) -> str:
    """Render the transcript (and optional summary) as Markdown."""
    meta = (f"**{t('md_meeting_date')}:** {started_at.strftime('%d.%m.%Y %H:%M')} · "
            f"**{t('md_meeting_duration')}:** {format_hms(duration_s)}")
    if source:
        meta += f" · **{t('md_meeting_source')}:** {source}"
    lines = [
        f"# {t('md_meeting_title')} {started_at.strftime('%Y-%m-%d %H:%M')}",
        "",
        meta,
        "",
    ]
    if summary:
        lines += [f"## {t('md_meeting_summary')}", "", summary.strip(), ""]
    lines += [f"## {t('md_meeting_transcript')}", ""]
    for seg in segments:
        text = (seg.get("text") or "").strip()
        if text:
            lines.append(f"**[{format_hms(seg.get('start', 0.0))}]** {text}")
            lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def _default_recorder_factory(kind: str):
    """Open a 16 kHz mono recorder for 'system' (loopback) or 'mic'.

    Called INSIDE the capture thread — soundcard/COM objects must be created
    and used on the same thread.
    """
    import soundcard as sc

    if kind == "system":
        spk = sc.default_speaker()
        mic = sc.get_microphone(spk.name, include_loopback=True)
    else:
        mic = sc.default_microphone()
    return mic.recorder(samplerate=SAMPLE_RATE, channels=1)


def _com_init() -> None:
    """Per-thread COM init required by Windows Core Audio in worker threads."""
    try:
        import ctypes

        ctypes.windll.ole32.CoInitializeEx(None, 0)  # COINIT_MULTITHREADED
    except Exception:
        pass


class MeetingRecorderService(BaseService):
    """State machine: idle -> recording -> processing -> idle."""

    def __init__(
        self,
        signals: AppSignals,
        settings_service: SettingsService,
        transcription_service: TranscriptionService,
        llm_service: LLMService | None = None,
        recorder_factory: Callable | None = None,
    ) -> None:
        self._signals = signals
        self._settings = settings_service
        self._transcription = transcription_service
        self._llm = llm_service
        self._recorder_factory = recorder_factory or _default_recorder_factory
        self._state = "idle"
        self._stop_evt = threading.Event()
        self._threads: list[threading.Thread] = []
        self._writers: dict[str, WavTrackWriter] = {}
        self._capture_error: str | None = None
        self._err_lock = threading.Lock()
        self._monitor_task: asyncio.Task | None = None
        self._process_task: asyncio.Task | None = None
        self._started_at: datetime | None = None
        self._base_path: Path | None = None
        # Per-app capture target (0/None = whole system via device loopback)
        self._target_pid: int | None = None
        self._target_name: str = ""
        # Fixed at recording start: the disk monitor must watch the folder the
        # WAVs are actually written to, not the (changeable) settings value.
        self._record_dir: Path | None = None
        self._auto_stop_key: str | None = None

    async def start(self) -> None:
        logger.info("MeetingRecorderService started")

    async def stop(self) -> None:
        """App shutdown: never lose what the user recorded.

        recording  -> finalize the WAV to disk WITHOUT transcribing (the model
                      is being torn down); resume_unprocessed() transcribes it
                      on the next start.
        processing -> give the in-flight transcription a short grace period,
                      then cancel it; the mixed .wav is already on disk and is
                      picked up by resume_unprocessed() next start.
        """
        if self._state == "processing":
            task = self._process_task
            if task is not None and task is not asyncio.current_task() and not task.done():
                done, pending = await asyncio.wait({task}, timeout=8)
                if pending:
                    task.cancel()
                    logger.warning(
                        "Meeting processing cancelled on shutdown — "
                        "wav kept, transcript resumes on next start")
            return
        if self._state != "recording":
            return
        self._stop_evt.set()
        if self._monitor_task is not None:
            self._monitor_task.cancel()
            self._monitor_task = None
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, self._join_threads)
        try:
            if self._base_path is not None and any(w.frames for w in self._writers.values()):
                final_wav = self._base_path.with_suffix(".wav")
                await loop.run_in_executor(None, self._mix_tracks_sync, final_wav)
                logger.info("Meeting recording preserved on shutdown: {}", final_wav)
        except Exception:
            logger.exception("preserving meeting recording on shutdown failed")
        self._state = "idle"

    @property
    def is_ready(self) -> bool:
        return True

    @property
    def state(self) -> str:
        return self._state

    # ─── Recording ──────────────────────────────────────────────────────────

    def _set_state(self, state: str) -> None:
        self._state = state
        self._signals.meeting_state.emit(state)

    def _output_dir(self) -> Path:
        configured = ""
        if self._settings.is_ready:
            configured = (self._settings.get().meeting_output_dir or "").strip()
        if configured:
            return Path(configured)
        return Path.home() / "Documents" / "VoxCast" / "Meetings"

    def _new_base_path(self, out_dir: Path, now: datetime) -> Path:
        base = out_dir / f"Meeting_{now.strftime('%Y-%m-%d_%H-%M')}"
        candidate, n = base, 2
        while candidate.with_suffix(".md").exists() or candidate.with_suffix(".wav").exists():
            candidate = base.with_name(f"{base.name}_{n}")
            n += 1
        return candidate

    @staticmethod
    def _free_disk_bytes(path: Path) -> int:
        try:
            return shutil.disk_usage(path).free
        except OSError:
            return MIN_FREE_DISK_BYTES  # unknown — don't block, monitor re-checks

    async def start_recording(self, target_pid: int | None = None,
                              target_name: str = "") -> None:
        """target_pid: capture only that process tree (per-app recording);
        None/0 = whole system via device loopback."""
        if self._state != "idle":
            logger.warning("start_recording ignored in state {}", self._state)
            return
        self._target_pid = int(target_pid) if target_pid else None
        self._target_name = target_name if self._target_pid else ""
        try:
            out_dir = self._output_dir()
            out_dir.mkdir(parents=True, exist_ok=True)
        except OSError as e:
            logger.error("Meeting output dir not writable: {}", e)
            self._signals.error_occurred.emit(t("err_meeting_folder"))
            return
        if self._free_disk_bytes(out_dir) < MIN_FREE_DISK_BYTES:
            self._signals.error_occurred.emit(t("err_meeting_disk"))
            return

        if self._recorder_factory is _default_recorder_factory:
            # soundcard's module-level COM setup must run ONCE on the main
            # thread. First-importing it inside a capture thread (which has
            # already joined the MTA via CoInitializeEx) makes soundcard's own
            # CoInitializeEx return S_FALSE and its import fail.
            try:
                import soundcard  # noqa: F401

                from . import soundcard_compat
                soundcard_compat.apply()
            except Exception as e:
                logger.error("soundcard unavailable: {}", e)
                self._signals.error_occurred.emit(t("err_meeting_capture"))
                return

        self._started_at = datetime.now()
        self._base_path = self._new_base_path(out_dir, self._started_at)
        self._record_dir = out_dir
        self._stop_evt.clear()
        self._capture_error = None
        self._auto_stop_key = None
        self._writers = {}
        self._threads = []

        record_mic = True
        if self._settings.is_ready:
            record_mic = self._settings.get().meeting_record_mic
        tracks = ["system"] + (["mic"] if record_mic else [])
        try:
            for kind in tracks:
                writer = WavTrackWriter(
                    self._base_path.with_name(f"{self._base_path.name}_{kind}.wav")
                )
                self._writers[kind] = writer
                th = threading.Thread(
                    target=self._capture_loop, args=(kind, writer),
                    name=f"meeting-{kind}", daemon=True,
                )
                self._threads.append(th)
                th.start()
        except Exception:
            # e.g. the second track's WAV can't be opened. Without this the
            # already-started capture thread would keep recording forever with
            # the state stuck at "idle" (unstoppable from the UI).
            logger.exception("Meeting track setup failed — aborting session")
            self._stop_evt.set()
            await asyncio.get_running_loop().run_in_executor(None, self._join_threads)
            for w in self._writers.values():
                try:
                    w.close()
                    w.final_path.unlink(missing_ok=True)
                except Exception:
                    logger.exception("track cleanup failed")
            self._writers = {}
            self._signals.error_occurred.emit(t("err_meeting_capture"))
            return

        self._set_state("recording")
        self._signals.meeting_elapsed.emit(0)
        self._monitor_task = asyncio.get_running_loop().create_task(self._monitor())
        logger.info("Meeting recording started -> {} (tracks: {})",
                    self._base_path, tracks)

    def _enter_recorder(self, kind: str):
        """Open + enter the recorder for a track.

        System track honours the per-app target (Process Loopback API) and
        falls back to whole-system device loopback if per-app capture cannot
        start (old Windows, target exited, activation failure)."""
        if (kind == "system" and self._target_pid
                and self._recorder_factory is _default_recorder_factory):
            try:
                from .process_loopback import ProcessLoopbackRecorder

                rec = ProcessLoopbackRecorder(self._target_pid)
                rec.__enter__()
                return rec
            except Exception:
                logger.exception(
                    "Per-app capture failed (pid={}) — device-loopback fallback",
                    self._target_pid)
                self._target_pid = None
                self._target_name = ""
                self._signals.tray_message.emit("VoxCast", t("meeting_app_fallback"))
        cm = self._recorder_factory(kind)
        cm.__enter__()
        return cm

    def _capture_loop(self, kind: str, writer: WavTrackWriter) -> None:
        _com_init()
        try:
            rec = self._enter_recorder(kind)
            try:
                while not self._stop_evt.is_set():
                    data = rec.record(CHUNK_FRAMES)
                    writer.write(data)
            finally:
                rec.__exit__(None, None, None)
        except Exception as e:
            logger.error("Meeting capture ({}) failed: {}", kind, e)
            with self._err_lock:
                if self._capture_error is None:
                    self._capture_error = f"{kind}: {e}"
            # The system track is essential; without it stop the whole session.
            # A dead mic track alone must not kill the meeting recording.
            if kind == "system":
                self._stop_evt.set()
        finally:
            try:
                writer.close()
            except Exception:
                logger.exception("closing {} track failed", kind)

    async def _monitor(self) -> None:
        loop = asyncio.get_running_loop()
        started = loop.time()
        last_disk = started
        while not self._stop_evt.is_set():
            await asyncio.sleep(1)
            if self._state != "recording":
                return
            elapsed = loop.time() - started
            self._signals.meeting_elapsed.emit(int(elapsed))
            if self._stop_evt.is_set():
                # a capture thread died — finalize what we have
                self._auto_stop_key = self._auto_stop_key or "err_meeting_capture"
                break
            if elapsed >= MAX_MEETING_SECONDS:
                self._auto_stop_key = "err_meeting_maxlen"
                break
            if loop.time() - last_disk >= DISK_CHECK_EVERY_S:
                last_disk = loop.time()
                # Watch the folder we are ACTUALLY writing to — the settings
                # value may have been changed mid-recording.
                watch = self._record_dir or self._output_dir()
                if self._free_disk_bytes(watch) < MIN_FREE_DISK_BYTES:
                    self._auto_stop_key = "err_meeting_disk"
                    break
        if self._state == "recording":
            if self._auto_stop_key:
                self._signals.error_occurred.emit(t(self._auto_stop_key))
            await self.stop_recording()

    # ─── Stop + processing pipeline ─────────────────────────────────────────

    async def stop_recording(self) -> None:
        if self._state != "recording":
            return
        self._set_state("processing")
        # Track ourselves so the shutdown path can grace-wait/cancel the
        # in-flight transcription instead of silently abandoning it.
        self._process_task = asyncio.current_task()
        self._stop_evt.set()
        # Don't cancel ourselves: the monitor task calls stop_recording() on
        # auto-stop, and cancelling the current task would abort the stop
        # sequence at the next await, leaving the state stuck at "processing".
        if self._monitor_task is not None and self._monitor_task is not asyncio.current_task():
            self._monitor_task.cancel()
        self._monitor_task = None
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, self._join_threads)
        try:
            await self._process()
        except asyncio.CancelledError:
            # shutdown grace period expired — the mixed wav (if reached) stays
            # on disk and resume_unprocessed() finishes the job next start
            logger.warning("Meeting processing cancelled")
            raise
        except Exception:
            logger.exception("Meeting processing failed")
            self._signals.error_occurred.emit(t("err_meeting_transcribe"))
        finally:
            self._process_task = None
            self._set_state("idle")

    def _join_threads(self) -> None:
        for th in self._threads:
            th.join(timeout=10)
        self._threads = []

    def _mix_tracks_sync(self, final_wav: Path) -> None:
        sys_wav = self._writers["system"].final_path
        mic_writer = self._writers.get("mic")
        if mic_writer is not None and mic_writer.frames > 0:
            mix_wav_files(sys_wav, mic_writer.final_path, final_wav)
            sys_wav.unlink(missing_ok=True)
            mic_writer.final_path.unlink(missing_ok=True)
        else:
            if mic_writer is not None:
                mic_writer.final_path.unlink(missing_ok=True)
            sys_wav.replace(final_wav)

    async def _process(self) -> None:
        if self._base_path is None or self._started_at is None:
            logger.error("Meeting _process without an active session")
            return
        frames = sum(w.frames for w in self._writers.values())
        if frames == 0:
            for w in self._writers.values():
                w.final_path.unlink(missing_ok=True)
            self._signals.error_occurred.emit(t("err_meeting_capture"))
            return
        if self._capture_error:
            # partial recording — tell the user but still deliver a transcript
            self._signals.tray_message.emit("VoxCast", t("meeting_partial_note"))

        # Some headsets (hardware-offloaded render paths) deliver a working
        # loopback stream that only ever contains silence. Warn honestly
        # instead of shipping a transcript hallucinated from noise.
        sys_writer = self._writers.get("system")
        if (sys_writer is not None and sys_writer.frames > 10 * SAMPLE_RATE
                and sys_writer.peak < 0.003):
            logger.warning("System track is silent (peak={:.4f}) — "
                           "likely an offloaded render device", sys_writer.peak)
            self._signals.tray_message.emit("VoxCast", t("meeting_system_silent"))

        loop = asyncio.get_running_loop()
        final_wav = self._base_path.with_suffix(".wav")
        await loop.run_in_executor(None, self._mix_tracks_sync, final_wav)
        # A track can die early (e.g. loopback device removed) — the real
        # session length is whatever the longest surviving track captured.
        duration_s = max(w.frames for w in self._writers.values()) / SAMPLE_RATE

        await self._transcribe_and_write(final_wav, self._started_at, duration_s)

    async def _transcribe_and_write(
        self, final_wav: Path, started_at: datetime, duration_s: float
    ) -> None:
        """Transcribe a finished meeting WAV and write the .md next to it.
        Shared by the live pipeline and resume_unprocessed()."""
        loop = asyncio.get_running_loop()
        self._signals.meeting_progress.emit(0.0)
        settings = self._settings.get() if self._settings.is_ready else None
        # A meeting is other people talking — it may be in a different language
        # than the user's dictation setting, or switch mid-call. Auto-detect per
        # segment unless the user explicitly pinned a meeting language.
        pinned = (getattr(settings, "meeting_language", "") or "").strip() if settings else ""
        text, meta = await self._transcription.transcribe_file(
            final_wav,
            language=pinned or None,
            with_timestamps=True,
            multilingual=not pinned,
            on_progress=lambda p: self._signals.meeting_progress.emit(p),
        )
        segments = meta.get("segments") or []
        if not text or not segments:
            # keep the audio for a retry — never throw away the user's meeting
            self._signals.error_occurred.emit(t("err_meeting_transcribe"))
            return
        self._signals.meeting_progress.emit(1.0)

        summary = await self._maybe_summarize(
            text, language=pinned or meta.get("language"))
        md = build_meeting_markdown(started_at, duration_s, segments, summary,
                                    source=self._target_name)
        md_path = final_wav.with_suffix(".md")
        await loop.run_in_executor(
            None, lambda: md_path.write_text(md, encoding="utf-8"))

        keep_audio = settings.meeting_keep_audio if settings else True
        if not keep_audio:
            final_wav.unlink(missing_ok=True)

        logger.info("Meeting transcript written: {} ({} segments, {:.0f}s audio)",
                    md_path, len(segments), duration_s)
        self._signals.meeting_done.emit(str(md_path))
        self._signals.tray_message.emit(
            t("meeting_done_title"), t("meeting_done_body"))

    # ─── Crash/quit recovery ────────────────────────────────────────────────

    def _find_unprocessed(self) -> list[Path]:
        """Meeting WAVs that never got their transcript (crash or quit while
        transcribing). Track files (*_system/_mic) and .part files never count."""
        try:
            wavs = sorted(self._output_dir().glob("Meeting_*.wav"))
        except OSError:
            return []
        return [
            w for w in wavs
            if not w.stem.endswith(("_system", "_mic"))
            and not w.with_suffix(".md").exists()
        ]

    @staticmethod
    def _started_at_from(wav: Path) -> datetime:
        """Recover the session start from 'Meeting_YYYY-MM-DD_HH-MM[...]'."""
        try:
            return datetime.strptime(wav.stem[8:24], "%Y-%m-%d_%H-%M")
        except ValueError:
            return datetime.fromtimestamp(wav.stat().st_mtime)

    @staticmethod
    def _wav_duration_s(wav: Path) -> float:
        with wave.open(str(wav), "rb") as wf:
            rate = wf.getframerate() or SAMPLE_RATE
            return wf.getnframes() / rate

    async def resume_unprocessed(self) -> None:
        """Finish transcripts for recordings interrupted by quit/crash.
        Call once at startup, after the transcription model is ready."""
        if self._state != "idle":
            return
        pending = self._find_unprocessed()
        if not pending:
            return
        loop = asyncio.get_running_loop()
        self._set_state("processing")
        self._signals.tray_message.emit("VoxCast", t("meeting_resume_note"))
        logger.info("Resuming {} unprocessed meeting recording(s)", len(pending))
        try:
            for wav in pending:
                started_at = self._started_at_from(wav)
                duration_s = await loop.run_in_executor(None, self._wav_duration_s, wav)
                await self._transcribe_and_write(wav, started_at, duration_s)
        except Exception:
            logger.exception("Resuming meeting transcription failed")
            self._signals.error_occurred.emit(t("err_meeting_transcribe"))
        finally:
            self._set_state("idle")

    async def _maybe_summarize(
        self, transcript: str, language: str | None = None
    ) -> str | None:
        settings = self._settings.get() if self._settings.is_ready else None
        if not settings or not settings.meeting_auto_summary:
            return None
        if not settings.llm_enabled:
            return None
        from services.llm_service import language_directive

        # Name the transcript language explicitly (pinned setting or Whisper's
        # detection) — "same language" alone drifts on small models.
        prompt = f"{SUMMARY_PROMPT}\n\n{language_directive(language)}"

        # Central enhancement on the server (its LLM key) when the user chose
        # it and the server offers it; otherwise the client's own key.
        router = self._transcription
        use_server = (getattr(settings, "enhance_source", "own") == "server"
                      and getattr(router, "remote_active", False)
                      and hasattr(router, "remote_backend")
                      and router.remote_backend.enhance_maybe())
        try:
            if use_server:
                return await router.remote_backend.enhance(transcript, prompt)
            if self._llm is None:
                return None
            from services import credentials
            key = credentials.get_secret(f"llm_{settings.llm_provider}")
            if not key:
                logger.warning("Meeting summary skipped: no API key for {}",
                               settings.llm_provider)
                return None
            return await self._llm.enhance_auto(
                text=transcript,
                system_prompt=prompt,
                provider=settings.llm_provider,
                preferred_model=settings.llm_model or "",
                api_key=key,
                # summaries of hour-long meetings need more output room than
                # dictation enhancement
                max_tokens=8192,
            )
        except Exception as e:
            logger.warning("Meeting summary failed: {}", e)
            self._signals.tray_message.emit("VoxCast", t("meeting_summary_failed"))
            return None
