"""The Sovereignty Loop — core orchestration.

Implements the governing pattern from issue #953:

    check cache → miss → infer → crystallize → return

This module provides wrapper functions that enforce the crystallization
protocol for each AI layer (perception, decision, narration) and a
decorator for general-purpose sovereignty enforcement.

Every function follows the same contract:
  1. Check local cache / rule store for a cached answer.
  2. On hit → record sovereign event, return cached answer.
  3. On miss → call the expensive model.
  4. Crystallize the model output into a durable local artifact.
  5. Record the model-call event + any new crystallizations.
  6. Return the result.

Refs: #953 (The Sovereignty Loop), #955, #956, #961
"""

from __future__ import annotations

import functools
import json
import logging
from collections.abc import Callable
from pathlib import Path
from typing import Any, TypeVar

from timmy.sovereignty.auto_crystallizer import (
    crystallize_reasoning,
    get_rule_store,
)
from timmy.sovereignty.metrics import emit_sovereignty_event, get_metrics_store

logger = logging.getLogger(__name__)

T = TypeVar("T")

# ── Module-level narration cache ─────────────────────────────────────────────

_narration_cache: dict[str, str] | None = None
_narration_cache_mtime: float = 0.0


def _load_narration_store() -> dict[str, str]:
    """Load narration templates from disk, with mtime-based caching."""
    global _narration_cache, _narration_cache_mtime

    from config import settings

    narration_path = Path(settings.repo_root) / "data" / "narration.json"
    if not narration_path.exists():
        _narration_cache = {}
        return _narration_cache

    try:
        mtime = narration_path.stat().st_mtime
    except OSError:
        if _narration_cache is not None:
            return _narration_cache
        return {}

    if _narration_cache is not None and mtime == _narration_cache_mtime:
        return _narration_cache

    try:
        with narration_path.open() as f:
            _narration_cache = json.load(f)
        _narration_cache_mtime = mtime
    except Exception:
        if _narration_cache is None:
            _narration_cache = {}

    return _narration_cache


# ── Perception Layer ──────────────────────────────────────────────────────────


async def sovereign_perceive(
    screenshot: Any,
    cache: Any,  # PerceptionCache
    vlm: Any,
    *,
    session_id: str = "",
    parse_fn: Callable[..., Any] | None = None,
    crystallize_fn: Callable[..., Any] | None = None,
) -> Any:
    """Sovereignty-wrapped perception: cache check → VLM → crystallize.

    Parameters
    ----------
    screenshot:
        The current frame / screenshot (numpy array or similar).
    cache:
        A :class:`~timmy.sovereignty.perception_cache.PerceptionCache`.
    vlm:
        An object with an async ``analyze(screenshot)`` method.
    session_id:
        Current session identifier for metrics.
    parse_fn:
        Optional function to parse the VLM response into game state.
        Signature: ``parse_fn(vlm_response) -> state``.
    crystallize_fn:
        Optional function to extract templates from VLM output.
        Signature: ``crystallize_fn(screenshot, state) -> list[Template]``.
        Defaults to ``perception_cache.crystallize_perception``.

    Returns
    -------
    Any
        The parsed game state (from cache or fresh VLM analysis).
    """
    # Step 1: check cache
    cached = cache.match(screenshot)
    if cached.confidence > 0.85 and cached.state is not None:
        await emit_sovereignty_event("perception_cache_hit", session_id=session_id)
        return cached.state

    # Step 2: cache miss — call VLM
    await emit_sovereignty_event("perception_vlm_call", session_id=session_id)
    raw = await vlm.analyze(screenshot)

    # Step 3: parse
    state = parse_fn(raw) if parse_fn is not None else raw

    # Step 4: crystallize
    if crystallize_fn is not None:
        new_templates = crystallize_fn(screenshot, state)
    else:
        from timmy.sovereignty.perception_cache import crystallize_perception

        new_templates = crystallize_perception(screenshot, state)

    if new_templates:
        cache.add(new_templates)
        cache.persist()
        for _ in new_templates:
            await emit_sovereignty_event(
                "skill_crystallized",
                metadata={"layer": "perception"},
                session_id=session_id,
            )

    return state


# ── Decision Layer ────────────────────────────────────────────────────────────


async def sovereign_decide(
    context: dict[str, Any],
    llm: Any,
    *,
    session_id: str = "",
    rule_store: Any | None = None,
    confidence_threshold: float = 0.8,
) -> dict[str, Any]:
    """Sovereignty-wrapped decision: rule check → LLM → crystallize.

    Parameters
    ----------
    context:
        Current game state / decision context.
    llm:
        An object with an async ``reason(context)`` method that returns
        a dict with at least ``"action"`` and ``"reasoning"`` keys.
    session_id:
        Current session identifier for metrics.
    rule_store:
        Optional :class:`~timmy.sovereignty.auto_crystallizer.RuleStore`.
        If ``None``, the module-level singleton is used.
    confidence_threshold:
        Minimum confidence for a rule to be used without LLM.

    Returns
    -------
    dict[str, Any]
        The decision result, with at least an ``"action"`` key.
    """
    store = rule_store if rule_store is not None else get_rule_store()

    # Step 1: check rules
    matching_rules = store.find_matching(context)
    if matching_rules:
        best = matching_rules[0]
        if best.confidence >= confidence_threshold:
            await emit_sovereignty_event(
                "decision_rule_hit",
                metadata={"rule_id": best.id, "confidence": best.confidence},
                session_id=session_id,
            )
            return {
                "action": best.action,
                "source": "crystallized_rule",
                "rule_id": best.id,
                "confidence": best.confidence,
            }

    # Step 2: rule miss — call LLM
    await emit_sovereignty_event("decision_llm_call", session_id=session_id)
    result = await llm.reason(context)

    # Step 3: crystallize the reasoning
    reasoning_text = result.get("reasoning", "")
    if reasoning_text:
        new_rules = crystallize_reasoning(reasoning_text, context=context)
        added = store.add_many(new_rules)
        for _ in range(added):
            await emit_sovereignty_event(
                "skill_crystallized",
                metadata={"layer": "decision"},
                session_id=session_id,
            )

    return result


# ── Narration Layer ───────────────────────────────────────────────────────────


async def sovereign_narrate(
    event: dict[str, Any],
    llm: Any | None = None,
    *,
    session_id: str = "",
    template_store: Any | None = None,
) -> str:
    """Sovereignty-wrapped narration: template check → LLM → crystallize.

    Parameters
    ----------
    event:
        The game event to narrate (must have at least ``"type"`` key).
    llm:
        An optional LLM for novel narration.  If ``None`` and no template
        matches, returns a default string.
    session_id:
        Current session identifier for metrics.
    template_store:
        Optional narration template store (dict-like mapping event types
        to template strings with ``{variable}`` slots).  If ``None``,
        uses mtime-cached templates from ``data/narration.json``.

    Returns
    -------
    str
        The narration text.
    """
    # Load templates from cache instead of disk every time
    if template_store is None:
        template_store = _load_narration_store()

    event_type = event.get("type", "unknown")

    # Step 1: check templates
    if event_type in template_store:
        template = template_store[event_type]
        try:
            text = template.format(**event)
            await emit_sovereignty_event("narration_template", session_id=session_id)
            return text
        except (KeyError, IndexError):
            # Template doesn't match event variables — fall through to LLM
            pass

    # Step 2: no template — call LLM if available
    if llm is not None:
        await emit_sovereignty_event("narration_llm", session_id=session_id)
        narration = await llm.narrate(event)

        # Step 3: crystallize — add template for this event type
        _crystallize_narration_template(event_type, narration, event, template_store)

        return narration

    # No LLM available — return minimal default
    await emit_sovereignty_event("narration_template", session_id=session_id)
    return f"[{event_type}]"


def _crystallize_narration_template(
    event_type: str,
    narration: str,
    event: dict[str, Any],
    template_store: dict[str, str],
) -> None:
    """Attempt to crystallize a narration into a reusable template.

    Replaces concrete values in the narration with format placeholders
    based on event keys, then saves to ``data/narration.json``.
    """
    global _narration_cache, _narration_cache_mtime

    from config import settings

    template = narration
    for key, value in event.items():
        if key == "type":
            continue
        if isinstance(value, str) and value and value in template:
            template = template.replace(value, f"{{{key}}}")

    template_store[event_type] = template

    narration_path = Path(settings.repo_root) / "data" / "narration.json"
    try:
        narration_path.parent.mkdir(parents=True, exist_ok=True)
        with narration_path.open("w") as f:
            json.dump(template_store, f, indent=2)
        # Update cache so next read skips disk
        _narration_cache = template_store
        _narration_cache_mtime = narration_path.stat().st_mtime
        logger.info("Crystallized narration template for event type '%s'", event_type)
    except Exception as exc:
        logger.warning("Failed to persist narration template: %s", exc)


# ── Sovereignty decorator ────────────────────────────────────────────────────


def sovereignty_enforced(
    layer: str,
    cache_check: Callable[..., Any] | None = None,
    crystallize: Callable[..., Any] | None = None,
) -> Callable:
    """Decorator that enforces the sovereignty protocol on any async function.

    Wraps an async function with the check-cache → miss → infer →
    crystallize → return pattern.  If ``cache_check`` returns a non-None
    result, the wrapped function is skipped entirely.

    Parameters
    ----------
    layer:
        The sovereignty layer name (``"perception"``, ``"decision"``,
        ``"narration"``).  Used for metric event names.
    cache_check:
        A callable ``(args, kwargs) -> cached_result | None``.
        If it returns non-None, the decorated function is not called.
    crystallize:
        A callable ``(result, args, kwargs) -> None`` called after the
        decorated function returns, to persist the result as a local artifact.

    Example
    -------
    ::

        @sovereignty_enforced(
            layer="decision",
            cache_check=lambda a, kw: rule_store.find_matching(kw.get("ctx")),
            crystallize=lambda result, a, kw: rule_store.add(extract_rules(result)),
        )
        async def decide(ctx):
            return await llm.reason(ctx)
    """

    sovereign_event = (
        f"{layer}_cache_hit"
        if layer in ("perception", "decision", "narration")
        else f"{layer}_sovereign"
    )
    miss_event = {
        "perception": "perception_vlm_call",
        "decision": "decision_llm_call",
        "narration": "narration_llm",
    }.get(layer, f"{layer}_model_call")

    def decorator(fn: Callable) -> Callable:
        @functools.wraps(fn)
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            session_id = kwargs.get("session_id", "")
            store = get_metrics_store()

            # Check cache
            if cache_check is not None:
                cached = cache_check(args, kwargs)
                if cached is not None:
                    store.record(sovereign_event, session_id=session_id)
                    return cached

            # Cache miss — run the model
            store.record(miss_event, session_id=session_id)
            result = await fn(*args, **kwargs)

            # Crystallize
            if crystallize is not None:
                try:
                    crystallize(result, args, kwargs)
                    store.record(
                        "skill_crystallized",
                        metadata={"layer": layer},
                        session_id=session_id,
                    )
                except Exception as exc:
                    logger.warning("Crystallization failed for %s: %s", layer, exc)

            return result

        return wrapper

    return decorator
