"""Prompt manager — save, load, search and render reusable AI prompt templates.

Prompts are stored in a JSON file (default: ~/.muravey-china/prompts.json).
Each prompt can contain ``{variable}`` placeholders that are substituted
at render time.  Built-in variables: ``{text}`` (selected text),
``{date}`` (YYYY-MM-DD), ``{time}`` (HH:MM:SS), ``{os}`` (platform name).
"""

from __future__ import annotations

import json
import logging
import platform
import re
import threading
import uuid
from datetime import datetime, timezone

UTC = timezone.utc
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)

_BUILTIN_VARS = ("text", "date", "time", "os")
_SHORTCUT_RE = re.compile(r"^/[a-z0-9_-]+$")


def _utcnow_iso() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


class PromptManager:
    """Thread-safe JSON-backed prompt template store."""

    def __init__(self, db_path: str | Path) -> None:
        self._path = Path(db_path).expanduser()
        self._lock = threading.Lock()
        self._ensure_file()

    # ------------------------------------------------------------------ #
    # Public API
    # ------------------------------------------------------------------ #

    def list_prompts(
        self,
        tag: str | None = None,
        search: str | None = None,
        category: str | None = None,
    ) -> list[dict[str, Any]]:
        """Return all prompts, optionally filtered.

        Args:
            tag:      Keep only prompts whose ``tags`` list contains this tag.
            search:   Case-insensitive substring search across name, prompt text,
                      and tags.
            category: Keep only prompts in this category (exact match).
                      Pass ``""`` to get uncategorised prompts.
        """
        with self._lock:
            prompts = self._load()

        if tag:
            prompts = [p for p in prompts if tag in p.get("tags", [])]

        if category is not None:
            prompts = [p for p in prompts if p.get("category", "") == category]

        if search:
            q = search.lower()
            prompts = [
                p
                for p in prompts
                if q in p.get("name", "").lower()
                or q in p.get("prompt", "").lower()
                or any(q in t.lower() for t in p.get("tags", []))
            ]

        return prompts

    def get_prompt(self, prompt_id: str) -> dict[str, Any] | None:
        """Return a single prompt by id, or None if not found."""
        with self._lock:
            prompts = self._load()
        for p in prompts:
            if p.get("id") == prompt_id:
                return p
        return None

    def save_prompt(self, prompt_data: dict[str, Any]) -> str:
        """Create or update a prompt.

        If ``prompt_data`` contains an ``id`` key, the existing entry is
        updated.  Otherwise a new UUID id is generated.

        New optional fields: ``category`` (str), ``shortcut`` (str, must start
        with ``/`` if non-empty), ``description`` (str).

        Raises:
            ValueError: if the shortcut is already used by another prompt.

        Returns:
            The id of the saved prompt.
        """
        shortcut = prompt_data.get("shortcut", "")
        if shortcut and not _SHORTCUT_RE.match(shortcut):
            raise ValueError(
                f"Invalid shortcut format '{shortcut}'. "
                "Must start with '/' and contain only a-z, 0-9, _ or -."
            )

        with self._lock:
            prompts = self._load()
            now = _utcnow_iso()

            # Uniqueness check for shortcut
            if shortcut:
                current_id = prompt_data.get("id", "")
                for p in prompts:
                    if p.get("shortcut", "") == shortcut and p.get("id") != current_id:
                        raise ValueError(
                            f"Shortcut '{shortcut}' already used by prompt '{p.get('name', p.get('id'))}'"
                        )

            if "id" in prompt_data and prompt_data["id"]:
                # Update existing
                for i, p in enumerate(prompts):
                    if p.get("id") == prompt_data["id"]:
                        prompts[i] = {**p, **prompt_data}
                        break
                else:
                    # id not found — treat as new
                    prompt_data = {**prompt_data}
                    prompt_data["id"] = str(uuid.uuid4())
                    prompt_data.setdefault("created", now)
                    prompt_data.setdefault("last_used", None)
                    prompt_data.setdefault("usage_count", 0)
                    prompt_data.setdefault("category", "")
                    prompt_data.setdefault("shortcut", "")
                    prompt_data.setdefault("description", "")
                    prompts.append(prompt_data)
            else:
                # Create new
                new_id = str(uuid.uuid4())
                entry: dict[str, Any] = {
                    "id": new_id,
                    "name": prompt_data.get("name", ""),
                    "prompt": prompt_data.get("prompt", ""),
                    "category": prompt_data.get("category", ""),
                    "shortcut": prompt_data.get("shortcut", ""),
                    "description": prompt_data.get("description", ""),
                    "tags": prompt_data.get("tags", []),
                    "created": now,
                    "last_used": None,
                    "usage_count": 0,
                }
                prompts.append(entry)
                prompt_data["id"] = new_id

            self._save(prompts)

        log.debug("Saved prompt '%s' (id=%s)", prompt_data.get("name"), prompt_data.get("id"))
        return str(prompt_data.get("id", ""))

    def delete_prompt(self, prompt_id: str) -> bool:
        """Delete a prompt by id.  Returns True if deleted, False if not found."""
        with self._lock:
            prompts = self._load()
            before = len(prompts)
            prompts = [p for p in prompts if p.get("id") != prompt_id]
            if len(prompts) == before:
                return False
            self._save(prompts)

        log.debug("Deleted prompt id=%s", prompt_id)
        return True

    def increment_usage(self, prompt_id: str) -> None:
        """Increment usage_count and set last_used to now."""
        with self._lock:
            prompts = self._load()
            for p in prompts:
                if p.get("id") == prompt_id:
                    p["usage_count"] = p.get("usage_count", 0) + 1
                    p["last_used"] = _utcnow_iso()
                    break
            self._save(prompts)

    def render_prompt(
        self,
        prompt_id: str,
        variables: dict[str, str] | None = None,
    ) -> str:
        """Render a prompt template with variable substitution.

        Substitutes ``{key}`` placeholders in the prompt text.  Built-in
        variables ``{date}``, ``{time}``, ``{os}`` are always available.
        User-supplied ``variables`` override built-ins if names clash.

        Missing variables leave the placeholder unchanged (no crash).

        Returns:
            Rendered prompt string, or empty string if prompt not found.
        """
        prompt = self.get_prompt(prompt_id)
        if prompt is None:
            log.warning("render_prompt: prompt id=%s not found", prompt_id)
            return ""

        template = prompt.get("prompt", "")
        now = datetime.now()
        ctx: dict[str, str] = {
            "date": now.strftime("%Y-%m-%d"),
            "time": now.strftime("%H:%M:%S"),
            "os": platform.system(),
            "text": "",
        }
        if variables:
            ctx.update(variables)

        try:
            return template.format_map(_SafeFormat(ctx))
        except Exception as exc:
            log.warning("render_prompt format error: %s", exc)
            return template

    def get_prompts_by_category(self) -> dict[str, list[dict[str, Any]]]:
        """Return prompts grouped by category.

        Returns a dict where keys are category names and values are lists of
        prompts in that category.  Uncategorised prompts are under key ``""``.
        Categories are sorted alphabetically; ``""`` is placed last.
        """
        with self._lock:
            prompts = self._load()

        result: dict[str, list[dict[str, Any]]] = {}
        for p in prompts:
            cat = p.get("category", "")
            result.setdefault(cat, []).append(p)

        # Sort: named categories first (alphabetically), then ""
        named = sorted(k for k in result if k)
        ordered: dict[str, list[dict[str, Any]]] = {}
        for k in named:
            ordered[k] = result[k]
        if "" in result:
            ordered[""] = result[""]
        return ordered

    def find_by_shortcut(self, shortcut: str) -> dict[str, Any] | None:
        """Find a prompt by its shortcut (case-insensitive).

        The shortcut can be passed with or without the leading ``/``.

        Returns:
            The matching prompt dict, or None if not found.
        """
        if not shortcut:
            return None

        # Normalise: ensure it has the leading /
        if not shortcut.startswith("/"):
            shortcut = "/" + shortcut
        shortcut_lower = shortcut.lower()

        with self._lock:
            prompts = self._load()

        for p in prompts:
            sc = p.get("shortcut", "")
            if sc and sc.lower() == shortcut_lower:
                return p
        return None

    def import_prompts(self, filepath: str | Path, merge: bool = False) -> int:
        """Import prompts from a JSON file.

        Args:
            filepath: Path to a JSON file containing a list of prompt dicts.
            merge:    If True, existing prompts with matching ``id`` are
                      updated.  If False (default), all existing prompts are
                      replaced.

        Returns:
            Number of prompts added or updated.

        Raises:
            OSError: if the file cannot be read.
            ValueError: if the file is not valid JSON or not a list.
        """
        path = Path(filepath).expanduser()
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError as exc:
            raise OSError(f"Cannot read import file: {exc}") from exc

        try:
            incoming: list[dict] = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ValueError(f"Invalid JSON in import file: {exc}") from exc

        if not isinstance(incoming, list):
            raise ValueError("Import file must contain a JSON array of prompts.")

        count = 0
        with self._lock:
            existing = self._load() if merge else []

            if merge:
                existing_by_id = {p.get("id"): p for p in existing}
                for item in incoming:
                    if not isinstance(item, dict):
                        continue
                    pid = item.get("id")
                    if pid and pid in existing_by_id:
                        existing_by_id[pid].update(item)
                    else:
                        if not pid:
                            item = {**item, "id": str(uuid.uuid4())}
                        existing.append(item)
                    count += 1
                self._save(existing)
            else:
                for item in incoming:
                    if not isinstance(item, dict):
                        continue
                    if not item.get("id"):
                        item = {**item, "id": str(uuid.uuid4())}
                    existing.append(item)
                    count += 1
                self._save(existing)

        log.info("Imported %d prompts from %s (merge=%s)", count, filepath, merge)
        return count

    def export_prompts(
        self,
        filepath: str | Path,
        category: str | None = None,
    ) -> int:
        """Export prompts to a JSON file.

        Args:
            filepath: Destination path.  Parent directories will be created.
            category: If given, export only prompts in this category.
                      If None (default), export all prompts.

        Returns:
            Number of prompts written.

        Raises:
            OSError: if the file cannot be written.
        """
        prompts = self.list_prompts(category=category)

        path = Path(filepath).expanduser()
        path.parent.mkdir(parents=True, exist_ok=True)
        try:
            path.write_text(
                json.dumps(prompts, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
        except OSError as exc:
            raise OSError(f"Cannot write export file: {exc}") from exc

        log.info("Exported %d prompts to %s", len(prompts), filepath)
        return len(prompts)

    # ------------------------------------------------------------------ #
    # Private helpers
    # ------------------------------------------------------------------ #

    def _ensure_file(self) -> None:
        """Create the JSON file and parent directories if they don't exist."""
        self._path.parent.mkdir(parents=True, exist_ok=True)
        if not self._path.exists():
            self._path.write_text("[]", encoding="utf-8")
            log.info("Created prompts file: %s", self._path)

    def _load(self) -> list[dict[str, Any]]:
        """Read and return the JSON list (caller must hold self._lock)."""
        try:
            data = json.loads(self._path.read_text(encoding="utf-8"))
            if not isinstance(data, list):
                log.warning("prompts.json is not a list — resetting")
                return []
            return data
        except json.JSONDecodeError as exc:
            log.warning("Corrupted prompts.json: %s — using empty list", exc)
            return []
        except OSError as exc:
            log.warning("Cannot read prompts.json: %s", exc)
            return []

    def _save(self, prompts: list[dict[str, Any]]) -> None:
        """Write the JSON list to disk (caller must hold self._lock)."""
        try:
            self._path.write_text(
                json.dumps(prompts, ensure_ascii=False, indent=2),
                encoding="utf-8",
            )
        except OSError as exc:
            log.error("Failed to write prompts.json: %s", exc)


class _SafeFormat(dict):
    """dict subclass that returns the key placeholder for missing keys."""

    def __missing__(self, key: str) -> str:
        return "{" + key + "}"
