"""Auto-Crystallizer for Groq/cloud reasoning chains.

Automatically analyses LLM reasoning output and extracts durable local
rules that can preempt future cloud API calls.  Each extracted rule is
persisted to ``data/strategy.json`` with confidence tracking.

Workflow:
  1. LLM returns a reasoning chain (e.g. "I chose heal because HP < 30%")
  2. ``crystallize_reasoning()`` extracts condition → action rules
  3. Rules are stored locally with initial confidence 0.5
  4. Successful rule applications increase confidence; failures decrease it
  5. Rules with confidence > 0.8 bypass the LLM entirely

Rule format (JSON)::

    {
        "id": "rule_abc123",
        "condition": "health_pct < 30",
        "action": "heal",
        "source": "groq_reasoning",
        "confidence": 0.5,
        "times_applied": 0,
        "times_succeeded": 0,
        "created_at": "2026-03-23T...",
        "updated_at": "2026-03-23T...",
        "reasoning_excerpt": "I chose to heal because health was below 30%"
    }

Refs: #961, #953 (The Sovereignty Loop — Section III.5)
"""

from __future__ import annotations

import hashlib
import json
import logging
import re
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from config import settings

logger = logging.getLogger(__name__)

# ── Constants ─────────────────────────────────────────────────────────────────

STRATEGY_PATH = Path(settings.repo_root) / "data" / "strategy.json"

#: Minimum confidence for a rule to bypass the LLM.
CONFIDENCE_THRESHOLD = 0.8

#: Minimum successful applications before a rule is considered reliable.
MIN_APPLICATIONS = 3

#: Confidence adjustment on successful application.
CONFIDENCE_BOOST = 0.05

#: Confidence penalty on failed application.
CONFIDENCE_PENALTY = 0.10

# ── Regex patterns for extracting conditions from reasoning ───────────────────

_CONDITION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
    # "because X was below/above/less than/greater than Y"
    (
        "threshold",
        re.compile(
            r"because\s+(\w[\w\s]*?)\s+(?:was|is|were)\s+"
            r"(?:below|above|less than|greater than|under|over)\s+"
            r"(\d+(?:\.\d+)?)\s*%?",
            re.IGNORECASE,
        ),
    ),
    # "when X is/was Y" or "if X is/was Y"
    (
        "state_check",
        re.compile(
            r"(?:when|if|since)\s+(\w[\w\s]*?)\s+(?:is|was|were)\s+"
            r"(\w[\w\s]*?)(?:\.|,|$)",
            re.IGNORECASE,
        ),
    ),
    # "X < Y" or "X > Y" or "X <= Y" or "X >= Y"
    (
        "comparison",
        re.compile(
            r"(\w[\w_.]*)\s*(<=?|>=?|==|!=)\s*(\d+(?:\.\d+)?)",
        ),
    ),
    # "chose X because Y"
    (
        "choice_reason",
        re.compile(
            r"(?:chose|selected|picked|decided on)\s+(\w+)\s+because\s+(.+?)(?:\.|$)",
            re.IGNORECASE,
        ),
    ),
    # "always X when Y" or "never X when Y"
    (
        "always_never",
        re.compile(
            r"(always|never)\s+(\w+)\s+when\s+(.+?)(?:\.|,|$)",
            re.IGNORECASE,
        ),
    ),
]


# ── Data classes ──────────────────────────────────────────────────────────────


@dataclass
class Rule:
    """A crystallised decision rule extracted from LLM reasoning."""

    id: str
    condition: str
    action: str
    source: str = "groq_reasoning"
    confidence: float = 0.5
    times_applied: int = 0
    times_succeeded: int = 0
    created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
    updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
    reasoning_excerpt: str = ""
    pattern_type: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

    @property
    def success_rate(self) -> float:
        """Fraction of successful applications."""
        if self.times_applied == 0:
            return 0.0
        return self.times_succeeded / self.times_applied

    @property
    def is_reliable(self) -> bool:
        """True when the rule is reliable enough to bypass the LLM."""
        return (
            self.confidence >= CONFIDENCE_THRESHOLD
            and self.times_applied >= MIN_APPLICATIONS
            and self.success_rate >= 0.6
        )


# ── Rule store ────────────────────────────────────────────────────────────────


class RuleStore:
    """Manages the persistent collection of crystallised rules.

    Rules are stored as a JSON list in ``data/strategy.json``.
    Thread-safe for read-only; writes should be serialised by the caller.
    """

    def __init__(self, path: Path | None = None) -> None:
        self._path = path or STRATEGY_PATH
        self._rules: dict[str, Rule] = {}
        self._load()

    # ── persistence ───────────────────────────────────────────────────────

    def _load(self) -> None:
        """Load rules from disk."""
        if not self._path.exists():
            self._rules = {}
            return
        try:
            with self._path.open() as f:
                data = json.load(f)
            self._rules = {}
            for entry in data:
                rule = Rule(**{k: v for k, v in entry.items() if k in Rule.__dataclass_fields__})
                self._rules[rule.id] = rule
            logger.debug("Loaded %d crystallised rules from %s", len(self._rules), self._path)
        except Exception as exc:
            logger.warning("Failed to load strategy rules: %s", exc)
            self._rules = {}

    def persist(self) -> None:
        """Write current rules to disk."""
        try:
            self._path.parent.mkdir(parents=True, exist_ok=True)
            with self._path.open("w") as f:
                json.dump(
                    [asdict(r) for r in self._rules.values()],
                    f,
                    indent=2,
                    default=str,
                )
            logger.debug("Persisted %d rules to %s", len(self._rules), self._path)
        except Exception as exc:
            logger.warning("Failed to persist strategy rules: %s", exc)

    # ── CRUD ──────────────────────────────────────────────────────────────

    def add(self, rule: Rule) -> None:
        """Add or update a rule and persist."""
        self._rules[rule.id] = rule
        self.persist()

    def add_many(self, rules: list[Rule]) -> int:
        """Add multiple rules. Returns count of new rules added."""
        added = 0
        for rule in rules:
            if rule.id not in self._rules:
                self._rules[rule.id] = rule
                added += 1
            else:
                # Update confidence if existing rule seen again
                existing = self._rules[rule.id]
                existing.confidence = min(1.0, existing.confidence + CONFIDENCE_BOOST)
                existing.updated_at = datetime.now(UTC).isoformat()
        if rules:
            self.persist()
        return added

    def get(self, rule_id: str) -> Rule | None:
        """Retrieve a rule by ID."""
        return self._rules.get(rule_id)

    def find_matching(self, context: dict[str, Any]) -> list[Rule]:
        """Find rules whose conditions match the given context.

        A simple keyword match: if the condition string contains keys
        from the context, and the rule is reliable, it is included.

        This is intentionally simple — a production implementation would
        use embeddings or structured condition evaluation.
        """
        matching = []
        context_str = json.dumps(context).lower()
        for rule in self._rules.values():
            if not rule.is_reliable:
                continue
            # Simple keyword overlap check
            condition_words = set(rule.condition.lower().split())
            if any(word in context_str for word in condition_words if len(word) > 2):
                matching.append(rule)
        return sorted(matching, key=lambda r: r.confidence, reverse=True)

    def record_application(self, rule_id: str, succeeded: bool) -> None:
        """Record a rule application outcome (success or failure)."""
        rule = self._rules.get(rule_id)
        if rule is None:
            return
        rule.times_applied += 1
        if succeeded:
            rule.times_succeeded += 1
            rule.confidence = min(1.0, rule.confidence + CONFIDENCE_BOOST)
        else:
            rule.confidence = max(0.0, rule.confidence - CONFIDENCE_PENALTY)
        rule.updated_at = datetime.now(UTC).isoformat()
        self.persist()

    @property
    def all_rules(self) -> list[Rule]:
        """Return all stored rules."""
        return list(self._rules.values())

    @property
    def reliable_rules(self) -> list[Rule]:
        """Return only reliable rules (above confidence threshold)."""
        return [r for r in self._rules.values() if r.is_reliable]

    def __len__(self) -> int:
        return len(self._rules)


# ── Extraction logic ──────────────────────────────────────────────────────────


def _make_rule_id(condition: str, action: str) -> str:
    """Deterministic rule ID from condition + action."""
    key = f"{condition.strip().lower()}:{action.strip().lower()}"
    return f"rule_{hashlib.sha256(key.encode()).hexdigest()[:12]}"


def crystallize_reasoning(
    llm_response: str,
    context: dict[str, Any] | None = None,
    source: str = "groq_reasoning",
) -> list[Rule]:
    """Extract actionable rules from an LLM reasoning chain.

    Scans the response text for recognisable patterns (threshold checks,
    state comparisons, explicit choices) and converts them into ``Rule``
    objects that can replace future LLM calls.

    Parameters
    ----------
    llm_response:
        The full text of the LLM's reasoning output.
    context:
        Optional context dict for metadata enrichment.
    source:
        Identifier for the originating model/service.

    Returns
    -------
    list[Rule]
        Extracted rules (may be empty if no patterns found).
    """
    rules: list[Rule] = []
    seen_ids: set[str] = set()

    for pattern_type, pattern in _CONDITION_PATTERNS:
        for match in pattern.finditer(llm_response):
            groups = match.groups()

            if pattern_type == "threshold" and len(groups) >= 2:
                variable = groups[0].strip().replace(" ", "_").lower()
                threshold = groups[1]
                # Determine direction from surrounding text
                action = _extract_nearby_action(llm_response, match.end())
                if "below" in match.group().lower() or "less" in match.group().lower():
                    condition = f"{variable} < {threshold}"
                else:
                    condition = f"{variable} > {threshold}"

            elif pattern_type == "comparison" and len(groups) >= 3:
                variable = groups[0].strip()
                operator = groups[1]
                value = groups[2]
                condition = f"{variable} {operator} {value}"
                action = _extract_nearby_action(llm_response, match.end())

            elif pattern_type == "choice_reason" and len(groups) >= 2:
                action = groups[0].strip()
                condition = groups[1].strip()

            elif pattern_type == "always_never" and len(groups) >= 3:
                modifier = groups[0].strip().lower()
                action = groups[1].strip()
                condition = f"{modifier}: {groups[2].strip()}"

            elif pattern_type == "state_check" and len(groups) >= 2:
                variable = groups[0].strip().replace(" ", "_").lower()
                state = groups[1].strip().lower()
                condition = f"{variable} == {state}"
                action = _extract_nearby_action(llm_response, match.end())

            else:
                continue

            if not action:
                action = "unknown"

            rule_id = _make_rule_id(condition, action)
            if rule_id in seen_ids:
                continue
            seen_ids.add(rule_id)

            # Extract a short excerpt around the match for provenance
            start = max(0, match.start() - 20)
            end = min(len(llm_response), match.end() + 50)
            excerpt = llm_response[start:end].strip()

            rules.append(
                Rule(
                    id=rule_id,
                    condition=condition,
                    action=action,
                    source=source,
                    pattern_type=pattern_type,
                    reasoning_excerpt=excerpt,
                    metadata=context or {},
                )
            )

    if rules:
        logger.info(
            "Auto-crystallizer extracted %d rule(s) from %s response",
            len(rules),
            source,
        )

    return rules


def _extract_nearby_action(text: str, position: int) -> str:
    """Try to extract an action verb/noun near a match position."""
    # Look at the next 100 chars for action-like words
    snippet = text[position : position + 100].strip()
    action_patterns = [
        re.compile(r"(?:so|then|thus)\s+(?:I\s+)?(\w+)", re.IGNORECASE),
        re.compile(r"→\s*(\w+)", re.IGNORECASE),
        re.compile(r"action:\s*(\w+)", re.IGNORECASE),
    ]
    for pat in action_patterns:
        m = pat.search(snippet)
        if m:
            return m.group(1).strip()
    return ""


# ── Module-level singleton ────────────────────────────────────────────────────

_store: RuleStore | None = None


def get_rule_store() -> RuleStore:
    """Return (or lazily create) the module-level rule store."""
    global _store
    if _store is None:
        _store = RuleStore()
    return _store
