"""Python implementations of the tools the agent runtime can invoke.

Every :py:class:`encoach.ai.tool` row in the DB points to one of the
handler functions registered here via :py:func:`register`. The DB row
holds the metadata (description, JSON Schema, admin toggle); the real
logic is Python so it can import Odoo models, run transactions and
reuse existing services (vector search, quality gates, etc.).

Tools follow a strict contract:

* Signature: ``handler(env, **params) -> dict``.
* The returned dict must be JSON-serialisable. It becomes the tool
  message the LLM sees next, so keys should be descriptive.
* Tools that write to the DB must set ``mutates=True`` on their catalogue
  row so the runtime wraps the call in a savepoint.
* Tools never raise: catch exceptions and return ``{"error": str(exc)}``
  so the agent can reason about failures and the top-level call keeps
  going instead of aborting the whole graph.

Adding a new tool
-----------------

1. Write a handler below, decorated with ``@register("namespace.name")``.
2. Add a seed row in ``data/agents_defaults.xml`` with the same key.
3. Bind it to one or more agents in the same seed file.
"""

from __future__ import annotations

import json
import logging
from typing import Any, Callable

_logger = logging.getLogger(__name__)

# Registry: tool key → handler callable.
_REGISTRY: dict[str, Callable[..., dict]] = {}


def register(key: str):
    """Decorator to register a tool handler under ``key``."""

    def decorator(func: Callable[..., dict]) -> Callable[..., dict]:
        if key in _REGISTRY:
            _logger.warning("Agent tool %r is being re-registered", key)
        _REGISTRY[key] = func
        return func

    return decorator


def get_handler(key: str) -> Callable[..., dict] | None:
    return _REGISTRY.get(key)


def list_keys() -> list[str]:
    return sorted(_REGISTRY.keys())


def invoke(env, key: str, params: dict | None = None) -> dict:
    """Resolve and invoke the handler for ``key`` with ``params``.

    All tool failures are normalised to ``{"error": "..."}`` so callers
    (LangGraph nodes) don't need to care about exceptions. A missing
    handler returns ``{"error": "unknown_tool"}``.
    """
    handler = _REGISTRY.get(key)
    if not handler:
        return {"error": f"unknown_tool:{key}"}
    try:
        return handler(env, **(params or {}))
    except TypeError as exc:
        # Bad arguments from the LLM — make the complaint readable.
        return {"error": f"bad_arguments: {exc}"}
    except Exception as exc:
        _logger.exception("agent tool %s failed", key)
        return {"error": str(exc)}


# =============================================================================
# Built-in tools
# =============================================================================
#
# Each handler is deliberately small: they're thin adapters over services
# we already have. The LLM gets a compact JSON payload to reason over.


# --- Retrieval ----------------------------------------------------------------
@register("resources.search")
def _search_resources(env, query: str = "", skill: str = "", cefr_level: str = "",
                       limit: int = 5, plan_id: int | None = None,
                       **_: Any) -> dict:
    """Semantic search over the LMS resource library.

    When ``plan_id`` is provided, retrieval is scoped to that plan's
    indexed reference sources only (uploaded PDFs, URLs, inline text).
    Without ``plan_id`` we search the global resource library.

    Returns titles + short snippets so the agent can cite existing
    materials instead of inventing new ones every run.
    """
    from odoo.addons.encoach_vector.services.embedding_service import (
        EmbeddingService,
    )
    try:
        svc = EmbeddingService(env)
        if plan_id:
            results = svc.search(
                query or "",
                content_type="course_plan_source",
                entity_id=int(plan_id),
                limit=int(limit or 5),
            )
        else:
            results = svc.search(query or "", limit=int(limit or 5))
    except Exception as exc:
        _logger.debug("resource vector search unavailable: %s", exc)
        results = []
    out = []
    for r in results or []:
        out.append({
            "id": r.get("content_id") or r.get("id"),
            "type": r.get("content_type") or "",
            "title": (r.get("metadata") or {}).get("title", ""),
            "snippet": (r.get("text") or "")[:400],
            "similarity": r.get("similarity"),
        })
    return {"query": query, "plan_id": plan_id, "count": len(out), "items": out}


@register("rubric.fetch")
def _fetch_rubric(env, rubric_id: int | None = None, skill: str = "", **_: Any) -> dict:
    """Return rubric criteria for a given id, or the newest rubric for a skill."""
    Rubric = env["encoach.rubric"].sudo() if "encoach.rubric" in env else None
    if Rubric is None:
        return {"error": "rubric_model_missing"}
    rec = None
    if rubric_id:
        rec = Rubric.browse(int(rubric_id))
        if not rec.exists():
            rec = None
    if rec is None and skill:
        rec = Rubric.search(
            [("skill", "=", skill)], order="create_date desc", limit=1,
        )
    if not rec:
        return {"error": "rubric_not_found"}
    criteria = []
    for crit in getattr(rec, "criterion_ids", rec.browse([])):
        criteria.append({
            "code": getattr(crit, "code", "") or "",
            "name": crit.name or "",
            "weight": getattr(crit, "weight", 0) or 0,
            "descriptors": getattr(crit, "descriptors", "") or "",
        })
    return {
        "id": rec.id,
        "name": rec.name,
        "skill": getattr(rec, "skill", "") or "",
        "criteria": criteria,
    }


@register("outcomes.fetch")
def _fetch_course_outcomes(env, course_id: int | None = None,
                            cefr_level: str = "", **_: Any) -> dict:
    """Return learning outcomes for a course (or CEFR level)."""
    LO = env["encoach.learning.objective"].sudo() \
        if "encoach.learning.objective" in env else None
    if LO is None:
        return {"error": "learning_objective_model_missing"}
    domain = []
    if course_id:
        domain.append(("course_id", "=", int(course_id)))
    if cefr_level:
        domain.append(("cefr_level", "=", cefr_level))
    records = LO.search(domain, limit=200)
    return {
        "count": len(records),
        "items": [{
            "id": r.id,
            "code": getattr(r, "code", "") or "",
            "skill": getattr(r, "skill", "") or "",
            "cefr_level": getattr(r, "cefr_level", "") or "",
            "description": r.name or getattr(r, "description", "") or "",
        } for r in records],
    }


@register("student.profile")
def _fetch_student_profile(env, student_id: int, **_: Any) -> dict:
    """Return a compact gap-profile the agent can use to personalise content."""
    SP = env["encoach.student.profile"].sudo() \
        if "encoach.student.profile" in env else None
    if SP is None:
        return {"error": "student_profile_model_missing"}
    rec = SP.search([("student_id", "=", int(student_id))], limit=1)
    if not rec:
        return {"error": "profile_not_found"}
    return {
        "student_id": int(student_id),
        "cefr_level": getattr(rec, "cefr_level", "") or "",
        "strengths_json": getattr(rec, "strengths_json", "") or "",
        "gaps_json": getattr(rec, "gaps_json", "") or "",
    }


# --- Quality gates ------------------------------------------------------------
@register("quality.cefr_check")
def _cefr_check(env, text: str = "", target_cefr: str = "b1", **_: Any) -> dict:
    """Grade the readability of ``text`` against a target CEFR band."""
    try:
        import textstat  # noqa: F401
        fk = textstat.flesch_kincaid_grade(text or "")
        fre = textstat.flesch_reading_ease(text or "")
    except Exception:
        fk, fre = None, None
    # Rough mapping — deliberately conservative; the LLM uses it as a hint.
    band_map = {"a1": (1, 3), "a2": (3, 5), "b1": (5, 7),
                "b2": (7, 9), "c1": (9, 12), "c2": (12, 20)}
    ok = True
    issues = []
    if fk is not None:
        lo, hi = band_map.get((target_cefr or "b1").lower(), (5, 7))
        if fk < lo - 0.5:
            ok = False
            issues.append(f"Text reads below {target_cefr.upper()} (FK={fk:.1f})")
        elif fk > hi + 0.5:
            ok = False
            issues.append(f"Text reads above {target_cefr.upper()} (FK={fk:.1f})")
    return {
        "ok": ok,
        "target_cefr": target_cefr,
        "flesch_kincaid": fk,
        "flesch_reading_ease": fre,
        "issues": issues,
    }


@register("quality.ai_detect")
def _ai_detect(env, text: str = "", **_: Any) -> dict:
    """Return AI-detection probability if GPTZero is configured, else neutral."""
    try:
        # Try to reuse whatever GPTZero wrapper the platform already has.
        from odoo.addons.encoach_ai.services.gptzero_service import (
            GPTZeroService,  # type: ignore
        )
        svc = GPTZeroService(env)
        return svc.score(text)
    except Exception as exc:
        _logger.debug("gptzero unavailable: %s", exc)
        return {"ok": True, "ai_probability": None, "note": "detector_unavailable"}


@register("quality.content_gate")
def _content_gate(env, text: str = "", cefr_level: str = "b1", **_: Any) -> dict:
    """Run the project's unified content-source gate (if installed)."""
    try:
        from odoo.addons.encoach_quality_gate.services.content_source_gate import (
            ContentSourceGate,  # type: ignore
        )
        gate = ContentSourceGate(env)
        return gate.check(text, cefr_level=cefr_level)
    except Exception as exc:
        _logger.debug("content_gate unavailable: %s", exc)
        return {"ok": True, "note": "gate_unavailable"}


# --- Persistence --------------------------------------------------------------
@register("course_plan.save")
def _save_course_plan(env, plan_vals: dict, weeks: list | None = None, **_: Any) -> dict:
    """Persist an AI-generated course plan. Used by the course_planner agent.

    ``plan_vals`` is the dict the LLM produced (after the runtime's JSON
    normalisation). ``weeks`` is the list of per-week rows. The handler is
    idempotent-friendly: it always creates new rows (agents are expected
    to decide whether to reuse an existing plan before calling this).
    """
    Plan = env["encoach.course.plan"].sudo() if "encoach.course.plan" in env else None
    Week = env["encoach.course.plan.week"].sudo() \
        if "encoach.course.plan.week" in env else None
    if Plan is None or Week is None:
        return {"error": "course_plan_models_missing"}
    plan = Plan.create(plan_vals or {})
    created_weeks = 0
    for w in weeks or []:
        try:
            Week.create({**w, "plan_id": plan.id})
            created_weeks += 1
        except Exception as exc:
            _logger.warning("agent tool course_plan.save: bad week row %r: %s", w, exc)
    return {"plan_id": plan.id, "weeks_created": created_weeks}


@register("course_plan.save_materials")
def _save_week_materials(env, plan_id: int, week_id: int,
                          materials: list | None = None, **_: Any) -> dict:
    Material = env["encoach.course.plan.material"].sudo() \
        if "encoach.course.plan.material" in env else None
    if Material is None:
        return {"error": "material_model_missing"}
    created = 0
    for m in materials or []:
        try:
            Material.create({
                **m,
                "plan_id": int(plan_id),
                "week_id": int(week_id),
                "body_json": json.dumps(m.get("body") or {}, ensure_ascii=False)
                if "body" in m else m.get("body_json", ""),
            })
            created += 1
        except Exception as exc:
            _logger.warning("agent tool save_materials: bad row %r: %s", m, exc)
    return {"plan_id": plan_id, "week_id": week_id, "materials_created": created}


# --- Scoring (best-effort wrappers over existing services) --------------------
@register("scoring.grade_writing")
def _grade_writing(env, rubric: str = "", task: str = "", response: str = "",
                    **_: Any) -> dict:
    from odoo.addons.encoach_ai.services.openai_service import OpenAIService
    svc = OpenAIService(env)
    try:
        return svc.grade_writing(rubric, task, response)
    except Exception as exc:
        return {"error": str(exc)}


@register("scoring.grade_speaking")
def _grade_speaking(env, rubric: str = "", transcript: str = "", **_: Any) -> dict:
    from odoo.addons.encoach_ai.services.openai_service import OpenAIService
    svc = OpenAIService(env)
    try:
        return svc.grade_speaking(rubric, transcript)
    except Exception as exc:
        return {"error": str(exc)}


# --- Media generation tools ---------------------------------------------------
#
# These bridge an agent decision (e.g. "this listening lesson needs an MP3")
# into the MediaService implementation. They are mutating tools — the seed
# row in agents_defaults.xml has ``mutates=True`` so the runtime wraps them
# in a savepoint.

@register("media.synthesize_audio")
def _media_synthesize_audio(env, material_id: int, voice: str = "",
                              language: str = "en-GB",
                              gender: str = "female",
                              provider: str = "polly", **_: Any) -> dict:
    Material = env["encoach.course.plan.material"].sudo() \
        if "encoach.course.plan.material" in env else None
    if Material is None:
        return {"error": "course_plan_material_model_missing"}
    rec = Material.browse(int(material_id))
    if not rec.exists():
        return {"error": "material_not_found"}
    from odoo.addons.encoach_ai_course.services.media_service import MediaService
    svc = MediaService(env)
    media = svc.synthesize_audio(
        rec, voice=voice or None, language=language or "en-GB",
        gender=gender or "female", provider=provider or "polly",
    )
    return {
        "media_id": media.id,
        "status": media.status,
        "download_url": media.download_url,
        "error": media.error or None,
    }


@register("media.generate_image")
def _media_generate_image(env, material_id: int, prompt: str = "",
                           size: str = "1024x1024",
                           style: str = "natural",
                           quality: str = "standard", **_: Any) -> dict:
    Material = env["encoach.course.plan.material"].sudo() \
        if "encoach.course.plan.material" in env else None
    if Material is None:
        return {"error": "course_plan_material_model_missing"}
    rec = Material.browse(int(material_id))
    if not rec.exists():
        return {"error": "material_not_found"}
    from odoo.addons.encoach_ai_course.services.media_service import MediaService
    svc = MediaService(env)
    media = svc.generate_image(
        rec, custom_prompt=prompt or None,
        size=size or "1024x1024",
        style=style or "natural",
        quality=quality or "standard",
    )
    return {
        "media_id": media.id,
        "status": media.status,
        "download_url": media.download_url,
        "error": media.error or None,
    }


@register("media.compose_video")
def _media_compose_video(env, material_id: int, **_: Any) -> dict:
    Material = env["encoach.course.plan.material"].sudo() \
        if "encoach.course.plan.material" in env else None
    if Material is None:
        return {"error": "course_plan_material_model_missing"}
    rec = Material.browse(int(material_id))
    if not rec.exists():
        return {"error": "material_not_found"}
    from odoo.addons.encoach_ai_course.services.media_service import MediaService
    svc = MediaService(env)
    media = svc.compose_video(rec)
    return {
        "media_id": media.id,
        "status": media.status,
        "download_url": media.download_url,
        "error": media.error or None,
    }
