"""LangGraph-backed AI agents and the tools they can invoke.

Architecture
------------

The platform already has:

* ``encoach.ai.prompt`` — versioned prompt *templates* with rendering.
* ``encoach.ai.log``    — per-call telemetry.
* ``OpenAIService``     — thin wrapper around the OpenAI Python SDK.

This module adds the **agent layer** that sits on top of those primitives:

* :py:class:`EncoachAIAgent` — a named, configurable agent (e.g.
  ``course_planner``, ``exam_generator``). Each agent has a system prompt,
  model choice, temperature budget, a graph topology (``simple``,
  ``plan_review_revise`` or ``rag``) and an M2M list of tools it is
  allowed to call.

* :py:class:`EncoachAITool` — a catalogue row describing one callable
  capability. The *implementation* lives in
  ``encoach_ai.services.agent_tools`` keyed by :py:attr:`key`; this model
  only stores the metadata (JSON Schema, human description, whether it
  mutates data, which audiences may use it). Admins toggle tools on or
  off per agent through the UI without touching Python code.

The runtime itself (LangGraph state machine, tool-routing loop, log
emission) is in :py:mod:`encoach_ai.services.agent_runtime`. Keeping the
models here and the execution engine there makes the runtime easy to
swap / upgrade without touching the DB schema.
"""

from __future__ import annotations

import json
import logging
import re

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

_logger = logging.getLogger(__name__)

# Keys follow the same shape as prompt keys: ``lowercase.dotted.identifiers``.
_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z0-9_]+)*$")

GRAPH_TYPES = [
    # Single LLM call, no tools. Lowest latency, used for deterministic tasks.
    ("simple", "Simple (single LLM call)"),
    # Plan → self-critique → optionally revise once. Used for long-form
    # generation (course plans, exam papers) where we want quality checks.
    ("plan_review_revise", "Plan → Review → Revise"),
    # Retrieval-augmented: runs the ``search_resources`` tool first, injects
    # the hits as context, then calls the LLM. Used for curriculum-aware
    # generation that must cite existing materials.
    ("rag", "Retrieval-augmented (RAG)"),
    # LLM decides which tools to call via OpenAI function-calling, in a
    # loop. Used for LMS tutor / study assistant / grading workflows that
    # may need to fetch rubrics, student profiles, etc.
    ("react", "ReAct (tool-calling loop)"),
]

TOOL_CATEGORIES = [
    ("retrieval", "Retrieval"),
    ("persistence", "Persistence"),
    ("quality", "Quality & gating"),
    ("scoring", "Scoring & grading"),
    ("reference", "Reference lookup"),
    ("media", "Media generation"),
    ("other", "Other"),
]

# Models we're OK advertising in the admin UI. Keep small — the whole point
# of the agent layer is to encapsulate model choice.
MODEL_CHOICES = [
    ("gpt-4o", "GPT-4o (quality)"),
    ("gpt-4o-mini", "GPT-4o mini (cheap / fast)"),
    ("gpt-4.1", "GPT-4.1"),
    ("gpt-4.1-mini", "GPT-4.1 mini"),
    ("gpt-3.5-turbo", "GPT-3.5 turbo (legacy)"),
]


# =============================================================================
# Tool catalogue
# =============================================================================
class EncoachAITool(models.Model):
    _name = "encoach.ai.tool"
    _description = "AI Agent Tool"
    _order = "category, sequence, name"

    key = fields.Char(
        required=True,
        index=True,
        help="Stable dotted identifier (e.g. 'resources.search'). The Python "
        "handler is resolved from this key via the agent_tools registry.",
    )
    name = fields.Char(required=True, translate=True)
    description = fields.Text(
        required=True, translate=True,
        help="Shown to the LLM when the tool is exposed as a callable. "
        "Keep it concrete: explain *when* to use the tool, not *how* it works.",
    )
    category = fields.Selection(
        TOOL_CATEGORIES, required=True, default="other", index=True,
    )
    schema_json = fields.Text(
        required=True,
        default="{}",
        help="JSON Schema (Draft-07 subset) describing the tool's parameters. "
        "Passed verbatim to OpenAI function-calling.",
    )
    mutates = fields.Boolean(
        default=False,
        help="If True, the tool writes to the database. Used by the runtime "
        "to wrap calls in a savepoint so a failed LLM step can't leave half-"
        "created records behind.",
    )
    sequence = fields.Integer(default=10)
    active = fields.Boolean(default=True, index=True)

    _sql_constraints = [
        ("tool_key_uniq", "unique(key)", "Tool key must be unique."),
    ]

    # ------------------------------------------------------------------
    @api.constrains("key")
    def _check_key(self):
        for rec in self:
            if not _KEY_RE.match(rec.key or ""):
                raise ValidationError(
                    f"Invalid tool key {rec.key!r}. Use lowercase dotted identifiers."
                )

    @api.constrains("schema_json")
    def _check_schema(self):
        for rec in self:
            try:
                parsed = json.loads(rec.schema_json or "{}")
            except Exception as exc:
                raise ValidationError(f"schema_json must be valid JSON: {exc}")
            if not isinstance(parsed, dict):
                raise ValidationError("schema_json must be a JSON object.")

    # ------------------------------------------------------------------
    def to_api_dict(self):
        self.ensure_one()
        try:
            schema = json.loads(self.schema_json or "{}")
        except Exception:
            schema = {}
        return {
            "id": self.id,
            "key": self.key,
            "name": self.name,
            "description": self.description,
            "category": self.category,
            "schema": schema,
            "mutates": bool(self.mutates),
            "active": bool(self.active),
        }

    def to_openai_tool(self):
        """Render this row in the shape OpenAI function-calling expects."""
        self.ensure_one()
        try:
            params = json.loads(self.schema_json or "{}")
        except Exception:
            params = {}
        # Ensure the JSON Schema is OpenAI-compatible (an object with
        # ``properties``). Fall back to an empty object if the author
        # forgot to wrap parameters.
        if "type" not in params:
            params = {"type": "object", "properties": params.get("properties", {})}
        return {
            "type": "function",
            "function": {
                "name": self.key.replace(".", "__"),
                "description": self.description or self.name,
                "parameters": params,
            },
        }


# =============================================================================
# Agent
# =============================================================================
class EncoachAIAgent(models.Model):
    _name = "encoach.ai.agent"
    _description = "AI Agent"
    _order = "sequence, name"

    key = fields.Char(
        required=True,
        index=True,
        help="Stable dotted identifier the platform uses to resolve this "
        "agent (e.g. 'course_planner'). Pipelines look the agent up by key "
        "via AgentRuntime.from_key().",
    )
    name = fields.Char(required=True, translate=True)
    description = fields.Text(translate=True)
    sequence = fields.Integer(default=10)
    active = fields.Boolean(default=True, index=True)

    # ------------------------------------------------------------------
    # Prompt & model wiring
    # ------------------------------------------------------------------
    system_prompt = fields.Text(
        required=True,
        translate=False,
        help="System message sent to the LLM. Referenced variables can be "
        "filled in by the caller via AgentRuntime.invoke(variables=...).",
    )
    prompt_key = fields.Char(
        help="Optional: key of an encoach.ai.prompt record. When set, the "
        "active version of that prompt *overrides* system_prompt at runtime. "
        "Use this when you want prompt authors to iterate without touching "
        "the agent config.",
    )
    model = fields.Selection(
        MODEL_CHOICES, required=True, default="gpt-4o",
        help="OpenAI chat model used for this agent's LLM calls.",
    )
    fallback_model = fields.Selection(
        MODEL_CHOICES, default="gpt-4o-mini",
        help="Model tried automatically if the primary model fails with a "
        "5xx / rate-limit error. Leave blank to disable the fallback.",
    )
    temperature = fields.Float(
        default=0.4,
        help="0.0 = deterministic, 1.0 = very creative. 0.3-0.5 is a sane "
        "default for structured-JSON generation.",
    )
    max_tokens = fields.Integer(default=4096)
    response_format = fields.Selection(
        [("text", "Text"), ("json", "JSON object")],
        default="json",
        help="`json` enables OpenAI's JSON mode and asks the LLM to return "
        "parseable JSON. Use `text` for free-form output (tutor chat, "
        "coach feedback).",
    )

    # ------------------------------------------------------------------
    # Graph & tools
    # ------------------------------------------------------------------
    graph_type = fields.Selection(
        GRAPH_TYPES, required=True, default="simple", index=True,
        help="Which LangGraph topology to use when invoking this agent.",
    )
    max_revisions = fields.Integer(
        default=1,
        help="For `plan_review_revise`: cap on how many times the agent may "
        "revise its own draft before emitting the final answer.",
    )
    quality_checks = fields.Char(
        default="",
        help="Comma-separated list of quality-gate tool keys to run after "
        "generation (e.g. 'quality.cefr_check,quality.ai_detect'). Used by "
        "the `plan_review_revise` topology.",
    )
    tool_ids = fields.Many2many(
        "encoach.ai.tool",
        "encoach_ai_agent_tool_rel",
        "agent_id", "tool_id",
        string="Tools",
        help="Tools this agent is allowed to call. For `react` graphs, "
        "these are exposed to the LLM as OpenAI function-calling tools; "
        "for `rag` / `plan_review_revise`, only tools whose key matches a "
        "pre-defined hook (e.g. `resources.search` for RAG, "
        "`quality.*` for review) are executed.",
    )

    tool_count = fields.Integer(compute="_compute_tool_count", store=False)

    _sql_constraints = [
        ("agent_key_uniq", "unique(key)", "Agent key must be unique."),
    ]

    # ------------------------------------------------------------------
    @api.depends("tool_ids")
    def _compute_tool_count(self):
        for rec in self:
            rec.tool_count = len(rec.tool_ids)

    @api.constrains("key")
    def _check_key(self):
        for rec in self:
            if not _KEY_RE.match(rec.key or ""):
                raise ValidationError(
                    f"Invalid agent key {rec.key!r}. "
                    "Use lowercase dotted identifiers (e.g. 'course_planner')."
                )

    @api.constrains("temperature")
    def _check_temperature(self):
        for rec in self:
            if rec.temperature < 0.0 or rec.temperature > 2.0:
                raise ValidationError("Temperature must be between 0.0 and 2.0")

    # ------------------------------------------------------------------
    # Lookup helpers
    # ------------------------------------------------------------------
    @api.model
    def get_by_key(self, key):
        """Return the active agent for ``key`` or an empty recordset."""
        return self.sudo().search(
            [("key", "=", key), ("active", "=", True)], limit=1,
        )

    def resolved_system_prompt(self, variables=None):
        """Resolve the prompt to send: versioned prompt (if set) or inline.

        ``variables`` are passed to ``encoach.ai.prompt.render`` when the
        agent is bound to a prompt key.
        """
        self.ensure_one()
        variables = variables or {}
        if self.prompt_key:
            prompt = self.env["encoach.ai.prompt"].sudo().get_active(self.prompt_key)
            if prompt:
                try:
                    return prompt.render(variables)
                except UserError:
                    # Missing variables — fall back to the inline prompt so
                    # the caller still gets *something* and logs tell us
                    # exactly which variable was missing.
                    _logger.warning(
                        "Prompt %s v%s has unfilled variables; falling back "
                        "to inline system_prompt for agent %s",
                        prompt.key, prompt.version, self.key,
                    )
        return self.system_prompt or ""

    def to_api_dict(self, *, include_prompt=True):
        self.ensure_one()
        data = {
            "id": self.id,
            "key": self.key,
            "name": self.name,
            "description": self.description or "",
            "model": self.model,
            "fallback_model": self.fallback_model or "",
            "temperature": self.temperature,
            "max_tokens": self.max_tokens,
            "response_format": self.response_format,
            "graph_type": self.graph_type,
            "max_revisions": self.max_revisions,
            "quality_checks": [
                k.strip() for k in (self.quality_checks or "").split(",") if k.strip()
            ],
            "prompt_key": self.prompt_key or "",
            "tool_count": len(self.tool_ids),
            "tool_keys": self.tool_ids.mapped("key"),
            "active": bool(self.active),
        }
        if include_prompt:
            data["system_prompt"] = self.system_prompt or ""
        return data
