"""Transcript persistence: append-only daily markdown + rolling JSON index. Audio cache pruned to last-N.

Data location: `~/Library/Application Support/Lumo/{transcripts,audio_cache}/`.

NOT inside the .app bundle, because Sparkle (and any manual reinstall via
`cp -R dist/Lirelle.app /Applications/`) atomically REPLACES the bundle. Data
stored inside `Contents/Resources/transcripts/` would be wiped on every
single update. Apple Support Directory survives reinstalls, code-signing
rotations, and Sparkle deltas — it's the canonical macOS data location for
per-user state owned by an app.

If a legacy bundle-relative transcripts dir is found AND ~/Library/...
doesn't yet exist, transcripts are migrated (copied) on first use so the
user keeps their history through this transition. After migration the
bundle copy is left in place untouched (deletion is forbidden per
OPS/CLAUDE.md Global Rule #1).
"""

from __future__ import annotations

import json
import os
import shutil
import tempfile
import uuid
import wave
from datetime import datetime
from pathlib import Path

import numpy as np


def default_data_root() -> Path:
    """`~/Library/Application Support/Lumo/` — survives bundle replacements."""
    p = Path.home() / "Library" / "Application Support" / "Lumo"
    p.mkdir(parents=True, exist_ok=True)
    return p


def _migrate_legacy_dir(legacy: Path, new: Path, label: str) -> None:
    """If `legacy` (bundle-relative) has content and `new` doesn't, copy it
    over. Leave `legacy` in place (never delete user data per Global Rule #1)."""
    if not legacy.exists() or legacy == new:
        return
    if new.exists() and any(new.iterdir()):
        return  # new location already populated — don't trample
    new.mkdir(parents=True, exist_ok=True)
    moved = 0
    for src in legacy.iterdir():
        dst = new / src.name
        if dst.exists():
            continue
        try:
            if src.is_dir():
                shutil.copytree(src, dst)
            else:
                shutil.copy2(src, dst)
            moved += 1
        except OSError as e:
            print(f"[store] migrate {label} {src.name}: {e}")
    if moved:
        print(f"[store] migrated {moved} {label} entries from {legacy} → {new}")


class Store:
    def __init__(self, root: Path | None = None,
                 transcripts_dirname: str = "transcripts",
                 audio_dirname: str = "audio_cache", keep_audio: int = 50,
                 legacy_root: Path | None = None):
        """`root` defaults to ~/Library/Application Support/Lumo/. Pass
        `legacy_root` (typically the .app bundle's Resources/) to opt into
        one-time migration of any pre-existing data living there."""
        self.root = Path(root) if root is not None else default_data_root()
        self.transcripts_dir = self.root / transcripts_dirname
        self.audio_dir = self.root / audio_dirname
        self.keep_audio = keep_audio
        self.index_path = self.transcripts_dir / "index.json"

        if legacy_root is not None and Path(legacy_root) != self.root:
            _migrate_legacy_dir(
                Path(legacy_root) / transcripts_dirname,
                self.transcripts_dir,
                "transcript",
            )
            _migrate_legacy_dir(
                Path(legacy_root) / audio_dirname,
                self.audio_dir,
                "audio-cache",
            )

        self.transcripts_dir.mkdir(parents=True, exist_ok=True)
        self.audio_dir.mkdir(parents=True, exist_ok=True)

        # CR #11 — serialise read-modify-write on index.json. Two
        # overlapping dictations (e.g. polished cleanup taking 10s while
        # a quick raw clip lands) used to load the same index, each
        # insert their own entry, and the second save overwrote the
        # first — dropping the slower one from the index even though its
        # text was already in the daily .md file. A simple lock here is
        # enough because all writes go through _index_append.
        import threading as _th
        self._index_lock = _th.Lock()

        # CR #13b — cache today's word total so the hot-key callback
        # doesn't read + JSON-parse all-of-history on every press.
        # `_words_cache_date` is the local-date the cached value covers;
        # bumped from append_transcript (the only place a fresh dictation
        # can land while the app is running) and lazily filled on first
        # read each day.
        self._words_cache_date: str | None = None
        self._words_cache_count: int = 0

    def append_transcript(self, text: str, lang: str | None = None,
                          ts: datetime | None = None,
                          cleanup_level: str = "raw",
                          text_raw: str | None = None) -> dict:
        """Append a transcript. `text` is what gets pasted / shown by default
        (= cleaned if cleanup ran, else raw). `text_raw` is the verbatim
        transcription — preserved for re-cleanup or audit."""
        if not text.strip():
            return {}
        ts = ts or datetime.now()
        entry_id = uuid.uuid4().hex[:12]
        day = ts.strftime("%Y-%m-%d")
        clock = ts.strftime("%H:%M:%S")
        md_path = self.transcripts_dir / f"{day}.md"

        if not md_path.exists():
            md_path.write_text(f"---\ndate: {day}\n---\n\n", encoding="utf-8")

        # markdown shows the cleaned version (or raw if no cleanup); if cleanup ran,
        # also append the raw version in a collapsible-style block so it's recoverable
        body_lines = [f"## {clock}", text.strip()]
        if text_raw and text_raw.strip() != text.strip():
            body_lines.append("")
            body_lines.append(f"<!-- raw: {text_raw.strip()} -->")
        body_lines.append("")
        body_lines.append("")
        with md_path.open("a", encoding="utf-8") as f:
            f.write("\n".join(body_lines))

        entry = {
            "id": entry_id,
            "ts": ts.isoformat(timespec="seconds"),
            "day": day,
            "clock": clock,
            "file": md_path.name,
            "lang": lang,
            "cleanup": cleanup_level,
            "char_count": len(text),
            "preview": text.strip()[:120],
            "text": text.strip(),
            "text_raw": (text_raw or text).strip(),
        }
        self._index_append(entry)
        # CR #13b — keep the word-count cache in sync. Cheap (split on
        # whitespace); we'd pay it anyway the next time something asked.
        self._bump_words_cache(day, text)
        return entry

    def _bump_words_cache(self, day: str, text: str) -> None:
        import re as _re
        n = len(_re.findall(r"\S+", text or ""))
        if self._words_cache_date != day:
            # First write for a new local day — reset.
            self._words_cache_date = day
            self._words_cache_count = n
        else:
            self._words_cache_count += n

    def words_used_today_cached(self, today: str | None = None) -> int:
        """O(1) for back-to-back hotkey presses. First call of the day
        walks the index once; subsequent calls return the cached count.
        Safe to call from the CGEventTap thread — no disk I/O on the
        warm path. CR #13b."""
        today = today or datetime.now().strftime("%Y-%m-%d")
        if self._words_cache_date == today:
            return self._words_cache_count
        # Cold path: rebuild for today. We still avoid re-parsing the
        # whole file if it hasn't changed by reusing _index_load; that
        # one load is the only disk read per local day.
        import re as _re
        total = 0
        for entry in self.index():
            if entry.get("day") != today:
                continue
            total += len(_re.findall(r"\S+", entry.get("text", "")))
        self._words_cache_date = today
        self._words_cache_count = total
        return total

    def _index_append(self, entry: dict) -> None:
        with self._index_lock:
            index = self._index_load()
            index.insert(0, entry)
            self._index_save(index)

    def _index_load(self) -> list[dict]:
        if not self.index_path.exists():
            return []
        try:
            return json.loads(self.index_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            return []

    def _index_save(self, index: list[dict]) -> None:
        fd, tmp = tempfile.mkstemp(dir=self.transcripts_dir, prefix=".index.", suffix=".json")
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as f:
                json.dump(index, f, ensure_ascii=False, indent=2)
            os.replace(tmp, self.index_path)
        except Exception:
            if os.path.exists(tmp):
                os.unlink(tmp)
            raise

    def index(self) -> list[dict]:
        return self._index_load()

    def save_audio_cache(self, audio: np.ndarray, sample_rate: int, ts: datetime | None = None) -> Path:
        ts = ts or datetime.now()
        name = ts.strftime("%Y-%m-%d_%H-%M-%S") + f"_{uuid.uuid4().hex[:6]}.wav"
        path = self.audio_dir / name
        pcm16 = np.clip(audio * 32767.0, -32768, 32767).astype(np.int16)
        with wave.open(str(path), "wb") as w:
            w.setnchannels(1)
            w.setsampwidth(2)
            w.setframerate(sample_rate)
            w.writeframes(pcm16.tobytes())
        self._prune_audio()
        return path

    def _prune_audio(self) -> None:
        if self.keep_audio <= 0:
            return
        wavs = sorted(self.audio_dir.glob("*.wav"), key=lambda p: p.stat().st_mtime, reverse=True)
        for p in wavs[self.keep_audio:]:
            try:
                p.unlink()
            except OSError:
                pass
