"""
fol_formalizasyon.inference — Model checking, axiom extraction, and convergence.

Provides:
  - Model              — An interpretation assigning domains and truth values
  - ModelChecker        — Evaluate Formula against a Model
  - extract_axiom_refs  — Find axiom references in text
  - check_axiom_in_model — Verify a single axiom in a model
  - check_all_axioms    — Verify all axioms against a model
  - yakinlasma          — Convergence score for the FOL lens [0, 1)

Governing axioms:
  AX1:  Core precondition — every domain element must be formally ordered
  AX43: Project station = İstidlal (rational inference)
  AX56: Max grade = İlmelyakîn (Hakkalyakîn permanently inaccessible)
  KV₃:  Observer non-interference — methodology detects, never creates
  KV₄:  Convergence bound 0 < C < 1
  KV₇:  Independence — no shared state with other lenses
  T6:   ConvergenceScore < 1.0
"""

import re
import json
from typing import Any, Callable, Optional

from .types import (
    Sort, SORT_CARDINALITY,
    Variable, Constant, FunctionApplication, Term,
    Predicate, Formula,
    Atomic, Equals, Not, And, Or, Implies, ForAll, Exists,
    Axiom, is_well_formed,
)
from .axioms import (
    get_axiom, get_theorem, get_kavaid,
    list_all_axioms, list_all_theorems, list_all_kavaid,
    defined_axiom_ids, gap_axiom_ids, gap_theorem_ids,
    INFERENCE_CHAINS,
)


# ---------------------------------------------------------------------------
# Model — Interpretation / Structure
# ---------------------------------------------------------------------------

class Model:
    """A first-order interpretation (structure) for the framework sorts.

    A Model assigns:
      - A domain (set of elements) to each Sort
      - Truth values to predicates via interpretation functions
      - Values to function symbols via interpretation functions

    Per AX1: Every element in any domain must be formally ordered.
    Per KV₃: The model observes structure, it does not create it.
    """

    def __init__(self) -> None:
        self._domains: dict[Sort, set[str]] = {}
        self._predicates: dict[str, Callable[..., bool]] = {}
        self._functions: dict[str, Callable[..., Any]] = {}
        self._constants: dict[str, tuple[str, Sort]] = {}

    def set_domain(self, sort: Sort, elements: set[str]) -> None:
        """Assign a domain to a sort.

        Per Appendix C.1: cardinality constraints apply.
        """
        card = SORT_CARDINALITY.get(sort)
        if card is not None and len(elements) > card:
            raise ValueError(
                f"Sort {sort.value} has cardinality bound {card}, "
                f"got {len(elements)} elements"
            )
        self._domains[sort] = set(elements)

    def get_domain(self, sort: Sort) -> set[str]:
        """Return the domain for a sort (empty set if not assigned)."""
        return self._domains.get(sort, set())

    def set_predicate(self, name: str,
                      interp: Callable[..., bool]) -> None:
        """Assign an interpretation function to a predicate name.

        The function should accept string arguments and return bool.
        """
        self._predicates[name] = interp

    def get_predicate(self, name: str) -> Optional[Callable[..., bool]]:
        """Return the interpretation function for a predicate."""
        return self._predicates.get(name)

    def set_function(self, name: str,
                     interp: Callable[..., Any]) -> None:
        """Assign an interpretation function to a function symbol."""
        self._functions[name] = interp

    def get_function(self, name: str) -> Optional[Callable[..., Any]]:
        """Return the interpretation function for a function symbol."""
        return self._functions.get(name)

    def set_constant(self, name: str, value: str, sort: Sort) -> None:
        """Assign a value to a constant name."""
        self._constants[name] = (value, sort)

    def get_constant(self, name: str) -> Optional[tuple[str, Sort]]:
        """Return (value, sort) for a constant, or None."""
        return self._constants.get(name)

    @property
    def domain_count(self) -> int:
        """Total number of assigned domains."""
        return len(self._domains)

    @property
    def predicate_count(self) -> int:
        """Total number of interpreted predicates."""
        return len(self._predicates)

    def summary(self) -> dict:
        """Return a summary of the model's contents."""
        return {
            "domains": {
                s.value: len(elems) for s, elems in self._domains.items()
            },
            "predicates": list(self._predicates.keys()),
            "functions": list(self._functions.keys()),
            "constants": list(self._constants.keys()),
        }


# ---------------------------------------------------------------------------
# ModelChecker — Evaluate formulas against a Model
# ---------------------------------------------------------------------------

class ModelChecker:
    """Evaluate formulas against a Model under a variable assignment.

    Supports:
      - Atomic formulas (predicate application)
      - Equality
      - Boolean connectives (Not, And, Or, Implies)
      - Quantifiers (ForAll, Exists)

    Per KV₃: Checker observes, never modifies the model.
    Per AX43: Operates at İstidlal (rational inference) level.
    """

    def __init__(self, model: Model) -> None:
        self._model = model

    def evaluate(self, formula: Formula,
                 assignment: dict[str, str] | None = None) -> bool:
        """Evaluate a formula in the model under a variable assignment.

        Args:
            formula:    The formula to evaluate
            assignment: Map from variable name to domain element

        Returns:
            True if the formula is satisfied, False otherwise.
        """
        if assignment is None:
            assignment = {}

        if isinstance(formula, Atomic):
            return self._eval_atomic(formula, assignment)
        elif isinstance(formula, Equals):
            return self._eval_equals(formula, assignment)
        elif isinstance(formula, Not):
            return not self.evaluate(formula.operand, assignment)
        elif isinstance(formula, And):
            return (self.evaluate(formula.left, assignment) and
                    self.evaluate(formula.right, assignment))
        elif isinstance(formula, Or):
            return (self.evaluate(formula.left, assignment) or
                    self.evaluate(formula.right, assignment))
        elif isinstance(formula, Implies):
            return (not self.evaluate(formula.antecedent, assignment) or
                    self.evaluate(formula.consequent, assignment))
        elif isinstance(formula, ForAll):
            return self._eval_forall(formula, assignment)
        elif isinstance(formula, Exists):
            return self._eval_exists(formula, assignment)
        else:
            raise TypeError(f"Unknown formula type: {type(formula)}")

    def _eval_atomic(self, formula: Atomic,
                     assignment: dict[str, str]) -> bool:
        """Evaluate an atomic formula P(t₁, ..., tₙ)."""
        interp = self._model.get_predicate(formula.predicate.name)
        if interp is None:
            # Uninterpreted predicate: default to False (KV₃: no creation)
            return False

        args = tuple(
            self._resolve_term(t, assignment) for t in formula.terms
        )
        return interp(*args)

    def _eval_equals(self, formula: Equals,
                     assignment: dict[str, str]) -> bool:
        """Evaluate t₁ = t₂."""
        left = self._resolve_term(formula.left, assignment)
        right = self._resolve_term(formula.right, assignment)
        return left == right

    def _eval_forall(self, formula: ForAll,
                     assignment: dict[str, str]) -> bool:
        """Evaluate ∀v.φ — true iff φ holds for all domain elements."""
        domain = self._model.get_domain(formula.variable.sort)
        if not domain:
            return True  # vacuously true for empty domain

        for elem in domain:
            new_assign = dict(assignment)
            new_assign[formula.variable.name] = elem
            if not self.evaluate(formula.body, new_assign):
                return False
        return True

    def _eval_exists(self, formula: Exists,
                     assignment: dict[str, str]) -> bool:
        """Evaluate ∃v.φ — true iff φ holds for at least one element."""
        domain = self._model.get_domain(formula.variable.sort)
        if not domain:
            return False  # no witnesses in empty domain

        for elem in domain:
            new_assign = dict(assignment)
            new_assign[formula.variable.name] = elem
            if self.evaluate(formula.body, new_assign):
                return True
        return False

    def _resolve_term(self, term: Term,
                      assignment: dict[str, str]) -> str:
        """Resolve a term to a value string."""
        if isinstance(term, Variable):
            val = assignment.get(term.name)
            if val is None:
                raise ValueError(
                    f"Unbound variable '{term.name}' in formula evaluation"
                )
            return val
        elif isinstance(term, Constant):
            # Check if there's a model constant override
            const_info = self._model.get_constant(term.name)
            if const_info is not None:
                return const_info[0]
            return term.name
        elif isinstance(term, FunctionApplication):
            func = self._model.get_function(term.func_name)
            if func is None:
                raise ValueError(
                    f"Uninterpreted function '{term.func_name}'"
                )
            args = tuple(
                self._resolve_term(a, assignment) for a in term.args
            )
            return func(*args)
        else:
            raise TypeError(f"Unknown term type: {type(term)}")


# ---------------------------------------------------------------------------
# Axiom extraction from text
# ---------------------------------------------------------------------------

# Regex patterns for axiom/theorem/kavaid references
_AX_PATTERN = re.compile(r'\bAX(\d+)\b', re.IGNORECASE)
_T_PATTERN = re.compile(r'\bT(\d+)\b')
_KV_PATTERN = re.compile(r'\bKV[_₁₂₃₄₅₆₇₈]?\s*(\d+)\b', re.IGNORECASE)
_KV_SUBSCRIPT = re.compile(r'\bKV([₁₂₃₄₅₆₇₈])\b')

_SUBSCRIPT_MAP = {'₁': '1', '₂': '2', '₃': '3', '₄': '4',
                  '₅': '5', '₆': '6', '₇': '7', '₈': '8'}


def extract_axiom_refs(text: str) -> dict[str, list[str]]:
    """Extract axiom, theorem, and kavaid references from text.

    Returns a dict with keys 'axioms', 'theorems', 'kavaid',
    each mapping to a sorted list of found IDs.

    Example:
        >>> extract_axiom_refs("Per AX17 and T6, with KV₄ constraint")
        {'axioms': ['AX17'], 'theorems': ['T6'], 'kavaid': ['KV4']}
    """
    axioms: set[str] = set()
    theorems: set[str] = set()
    kavaid: set[str] = set()

    # Extract AX references
    for m in _AX_PATTERN.finditer(text):
        ax_id = f"AX{m.group(1)}"
        if ax_id not in gap_axiom_ids():
            axioms.add(ax_id)

    # Extract T references (careful: avoid matching common words)
    for m in _T_PATTERN.finditer(text):
        num = int(m.group(1))
        t_id = f"T{num}"
        if 1 <= num <= 18 and t_id not in gap_theorem_ids():
            theorems.add(t_id)

    # Extract KV references (both KV1 and KV₁ forms)
    for m in _KV_PATTERN.finditer(text):
        kv_id = f"KV{m.group(1)}"
        kavaid.add(kv_id)

    for m in _KV_SUBSCRIPT.finditer(text):
        digit = _SUBSCRIPT_MAP.get(m.group(1), m.group(1))
        kv_id = f"KV{digit}"
        kavaid.add(kv_id)

    return {
        "axioms": sorted(axioms),
        "theorems": sorted(theorems),
        "kavaid": sorted(kavaid),
    }


def count_axiom_coverage(text: str) -> tuple[int, int, float]:
    """Count how many defined axioms are referenced in the text.

    Returns (found_count, total_defined, coverage_ratio).
    Coverage ratio is always < 1.0 per T6/KV₄.
    """
    refs = extract_axiom_refs(text)
    found = len(refs["axioms"])
    total = len(defined_axiom_ids())
    ratio = found / total if total > 0 else 0.0
    # KV₄: cap coverage below 1.0
    ratio = min(ratio, 0.9999)
    return found, total, ratio


# ---------------------------------------------------------------------------
# Axiom checking against a Model
# ---------------------------------------------------------------------------

def check_axiom_in_model(axiom_id: str, model: Model) -> tuple[bool, str]:
    """Check whether a specific axiom's formula holds in a model.

    Returns (satisfied, message).
    If the axiom has no formula (None), returns (True, "no formula to check").
    """
    ax = get_axiom(axiom_id)
    if ax is None:
        return False, f"Axiom '{axiom_id}' not found"

    if ax.formula is None:
        return True, f"{axiom_id}: no formula to check (text-only axiom)"

    checker = ModelChecker(model)
    try:
        result = checker.evaluate(ax.formula)
        if result:
            return True, f"{axiom_id} ({ax.name}): SATISFIED"
        else:
            return False, f"{axiom_id} ({ax.name}): NOT SATISFIED"
    except (ValueError, TypeError) as e:
        return False, f"{axiom_id}: evaluation error: {e}"


def check_all_axioms(model: Model) -> dict[str, tuple[bool, str]]:
    """Check all defined axioms against a model.

    Returns dict mapping axiom ID to (satisfied, message).
    """
    results: dict[str, tuple[bool, str]] = {}
    for ax in list_all_axioms():
        results[ax.id] = check_axiom_in_model(ax.id, model)
    return results


# ---------------------------------------------------------------------------
# Convergence score — yakinlasma for FOL lens
# ---------------------------------------------------------------------------

def yakinlasma(text: str, model: Model | None = None) -> float:
    """Compute the convergence score for the FOL lens applied to text.

    Per AGENTS.md Appendix C.4:
      yakinlasma: S_Mercek × S_Text → [0, 1)
      Strict upper bound < 1.0

    Components:
      1. Axiom coverage in text (weight: 0.3)
      2. Theorem coverage in text (weight: 0.2)
      3. Kavaid coverage in text (weight: 0.2)
      4. Model satisfaction ratio (weight: 0.3) — if model provided

    Returns:
        Convergence score in [0, 1), clamped per KV₄.
    """
    refs = extract_axiom_refs(text)

    # Component 1: Axiom coverage
    total_axioms = len(defined_axiom_ids())
    ax_coverage = len(refs["axioms"]) / total_axioms if total_axioms > 0 else 0.0

    # Component 2: Theorem coverage
    all_theorems = list_all_theorems()
    total_theorems = len(all_theorems)
    th_coverage = len(refs["theorems"]) / total_theorems if total_theorems > 0 else 0.0

    # Component 3: Kavaid coverage
    all_kavaid = list_all_kavaid()
    total_kavaid = len(all_kavaid)
    kv_coverage = len(refs["kavaid"]) / total_kavaid if total_kavaid > 0 else 0.0

    # Component 4: Model satisfaction (if model provided)
    model_score = 0.0
    if model is not None:
        results = check_all_axioms(model)
        if results:
            satisfied = sum(1 for ok, _ in results.values() if ok)
            model_score = satisfied / len(results)

    # Weighted composite
    if model is not None:
        score = (0.3 * ax_coverage + 0.2 * th_coverage +
                 0.2 * kv_coverage + 0.3 * model_score)
    else:
        # Without model, redistribute weight
        score = (0.45 * ax_coverage + 0.30 * th_coverage +
                 0.25 * kv_coverage)

    # KV₄ / T6: Strict upper bound < 1.0
    return min(score, 0.9999)


# ---------------------------------------------------------------------------
# Framework integrity checks
# ---------------------------------------------------------------------------

def verify_inference_chain(chain_id: str) -> tuple[bool, list[str]]:
    """Verify that all nodes in an inference chain are defined.

    Returns (all_defined, list_of_missing_ids).
    """
    chain = INFERENCE_CHAINS.get(chain_id)
    if chain is None:
        return False, [f"Chain '{chain_id}' not found"]

    missing = []
    for node_id in chain["nodes"]:
        if node_id.startswith("AX"):
            if get_axiom(node_id) is None:
                missing.append(node_id)
        elif node_id.startswith("T"):
            if get_theorem(node_id) is None:
                missing.append(node_id)
        elif node_id.startswith("KV"):
            if get_kavaid(node_id) is None:
                missing.append(node_id)
        # else: skip non-standard nodes

    return len(missing) == 0, missing


def verify_all_chains() -> dict[str, tuple[bool, list[str]]]:
    """Verify all 7 inference chains."""
    results = {}
    for chain_id in INFERENCE_CHAINS:
        results[chain_id] = verify_inference_chain(chain_id)
    return results


def framework_summary() -> dict:
    """Return a summary of the framework's contents.

    Per AX57 (Transparency): disclose what the lens covers.
    """
    return {
        "axioms_defined": len(list_all_axioms()),
        "axioms_gap": len(gap_axiom_ids()),
        "theorems_defined": len(list_all_theorems()),
        "theorems_gap": len(gap_theorem_ids()),
        "kavaid_defined": len(list_all_kavaid()),
        "inference_chains": len(INFERENCE_CHAINS),
        "sorts": len(Sort),
        "epistemic_grade": "İlmelyakîn (max per AX56)",
        "coverage_bound": "6/7 (per T17)",
        "convergence_bound": "< 1.0 (per KV₄/T6)",
    }
