"""Versioned AI prompt templates.

Context (ADR-0004, P3.4 hardening release): we want non-engineers to iterate
on LLM prompts without shipping code. Every time an author edits a prompt we
keep the previous revision around so we can A/B test, audit, and roll back.

Design:

* A **prompt** is identified by its ``key`` (e.g. ``exam.mcq.generate``) — a
  stable, dotted string that callers reference from Python.
* Each save produces a new **version** row (bumped ``version`` int). The old
  rows stay with ``is_active = False`` so history is preserved; only one row
  per ``key`` can be active at a time (enforced in ``write``/``create``).
* Callers look the prompt up with ``get_active(key)`` and render it with
  ``render(variables)``. Rendering uses :py:meth:`str.format_map` so templates
  are just ``{variable}`` placeholders — no new DSL to learn.

This module intentionally does **not** call the LLM itself. It is a
content-management layer that the existing ``encoach_ai.services.*`` pipelines
can adopt at their own pace (falling back to their hard-coded strings until a
prompt with the matching key is created in the UI).
"""

from __future__ import annotations

import logging
import re
from typing import Iterable

from odoo import api, fields, models
from odoo.exceptions import UserError, ValidationError

_logger = logging.getLogger(__name__)

# Keys look like ``namespace.sub_namespace.name`` — lowercase, dotted.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
# Variables pulled from ``{foo}`` / ``{foo.bar}`` placeholders.
_VAR_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_.]*)\}")


class EncoachAIPrompt(models.Model):
    _name = "encoach.ai.prompt"
    _description = "Versioned AI prompt template"
    _order = "key, version desc"
    _rec_name = "display_name"

    key = fields.Char(
        required=True,
        index=True,
        help="Stable dotted identifier (e.g. 'exam.mcq.generate'). "
        "Callers reference this from Python.",
    )
    version = fields.Integer(required=True, default=1, index=True)
    title = fields.Char(
        required=True,
        help="Short human label shown in the admin editor.",
    )
    description = fields.Text(
        help="Author intent, expected variables, known limitations.",
    )
    content = fields.Text(
        required=True,
        help="Template body. Use {variable} placeholders; "
        "render() fills them via str.format_map.",
    )
    is_active = fields.Boolean(
        default=False, index=True,
        help="Exactly one active row per key. Creating/activating a new row "
        "automatically deactivates the previous active version.",
    )
    author_id = fields.Many2one("res.users", ondelete="set null", readonly=True)
    activated_at = fields.Datetime(readonly=True)

    # Denormalised for quick display/search. Recomputed on save.
    variables = fields.Char(
        compute="_compute_variables", store=True,
        help="Comma-separated list of {variable} placeholders found in content.",
    )
    display_name = fields.Char(
        compute="_compute_display_name", store=True,
    )

    _sql_constraints = [
        (
            "key_version_uniq",
            "unique(key, version)",
            "A prompt version must be unique for its key.",
        ),
    ]

    # ------------------------------------------------------------------
    # Computed fields
    # ------------------------------------------------------------------
    @api.depends("content")
    def _compute_variables(self):
        for rec in self:
            if not rec.content:
                rec.variables = ""
                continue
            found = sorted(set(_VAR_RE.findall(rec.content)))
            rec.variables = ",".join(found)

    @api.depends("key", "version", "title")
    def _compute_display_name(self):
        for rec in self:
            rec.display_name = f"{rec.key} v{rec.version} — {rec.title or ''}".rstrip(" —")

    # ------------------------------------------------------------------
    # Validation
    # ------------------------------------------------------------------
    @api.constrains("key")
    def _check_key_shape(self):
        for rec in self:
            if not rec.key or not _KEY_RE.match(rec.key):
                raise ValidationError(
                    f"Invalid prompt key {rec.key!r}. "
                    "Use lowercase dotted identifiers like 'exam.mcq.generate'."
                )

    @api.constrains("content")
    def _check_content_non_empty(self):
        for rec in self:
            if not (rec.content or "").strip():
                raise ValidationError("Prompt content cannot be empty.")

    # ------------------------------------------------------------------
    # Create / write — enforce "one active per key"
    # ------------------------------------------------------------------
    @api.model_create_multi
    def create(self, vals_list):
        # Default version = max(existing) + 1 when caller omits it.
        for vals in vals_list:
            if "version" not in vals or not vals.get("version"):
                key = vals.get("key")
                existing = self.search(
                    [("key", "=", key)], order="version desc", limit=1,
                )
                vals["version"] = (existing.version + 1) if existing else 1
            vals.setdefault("author_id", self.env.user.id)
            if vals.get("is_active"):
                vals.setdefault("activated_at", fields.Datetime.now())
        records = super().create(vals_list)
        # Post-create: enforce exclusivity for any rows created as active.
        for rec in records:
            if rec.is_active:
                rec._deactivate_siblings()
        return records

    def write(self, vals):
        res = super().write(vals)
        if vals.get("is_active"):
            now = fields.Datetime.now()
            for rec in self:
                if rec.is_active and not rec.activated_at:
                    super(EncoachAIPrompt, rec).write({"activated_at": now})
                rec._deactivate_siblings()
        return res

    def _deactivate_siblings(self):
        for rec in self:
            if not rec.is_active:
                continue
            siblings = self.search([
                ("key", "=", rec.key),
                ("id", "!=", rec.id),
                ("is_active", "=", True),
            ])
            if siblings:
                siblings.write({"is_active": False})

    # ------------------------------------------------------------------
    # Public API (called by pipelines / controllers)
    # ------------------------------------------------------------------
    @api.model
    def get_active(self, key: str):
        """Return the active prompt record for ``key`` (or an empty set)."""
        return self.sudo().search(
            [("key", "=", key), ("is_active", "=", True)], limit=1,
        )

    def render(self, variables: dict | None = None) -> str:
        """Fill ``{placeholders}`` using ``variables`` (dict)."""
        self.ensure_one()
        variables = variables or {}
        missing = self._missing_variables(variables.keys())
        if missing:
            raise UserError(
                f"Missing prompt variables for {self.key} v{self.version}: "
                f"{', '.join(sorted(missing))}"
            )
        try:
            return self.content.format_map(_SafeFormatDict(variables))
        except Exception as exc:
            _logger.exception("prompt render failed for %s v%s", self.key, self.version)
            raise UserError(f"Prompt render failed: {exc}") from exc

    def _missing_variables(self, provided: Iterable[str]) -> set[str]:
        self.ensure_one()
        required = set(_VAR_RE.findall(self.content or ""))
        provided_root = {p.split(".")[0] for p in provided}
        return {v for v in required if v.split(".")[0] not in provided_root}


class _SafeFormatDict(dict):
    """Dict that returns ``''`` for dotted/nested lookups str.format can't resolve.

    We don't advertise ``{foo.bar}`` as supported (the editor shows top-level
    variables only), but we also don't want rendering to explode if a caller
    passes an object with attributes.
    """

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