"""
fw_server/adapters.py — Stateless adapters: JSON dict → lens yakinlasma → LensResult.

Design invariants:
  KV₇:  Each adapter creates a FRESH instance per call — zero shared state.
  AX27: Adapters are transparent vessels; they mediate but don't originate.
  AX56: Grade never reaches Hakkalyakîn.
  T6:   Score clamped < 1.0.

Each ``run_*`` function accepts a plain ``dict`` (from MCP JSON) and
returns a ``bileshke.types.LensResult``.  The adapters translate between
the wire format and the heterogeneous yakinlasma signatures.
"""

from __future__ import annotations

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

from bileshke.types import EpistemicGrade, LensId, LensResult, clamp_score
from fw_server.grade_map import pass_ratio_to_grade, score_to_grade
from fw_server.tracer import trace


# =====================================================================
# Text-based domain vocabulary for content-aware scoring
# =====================================================================
# When no structured model is provided, adapters scan user text for
# domain-specific terms and produce a score proportional to coverage.
# This ensures lenses respond to content even without pre-built models.

_ONTOLOJI_TERMS: frozenset = frozenset({
    # Framework concepts (DUSUN.md §2)
    "tecelli", "hakikat", "mahiyet", "esma", "esmâ", "sifat", "sıfat",
    "isim", "fiil", "eser", "zat", "zât", "şuunat", "suunat",
    "vahidiyet", "ehadiyet", "harfi", "ismi", "fakr", "acz",
    "tereşşuhat", "sudur", "delalet", "istinad", "mana-yı harfi",
    "ne ayn ne gayr", "ontolojik", "ontological",
    # The 7 Sifat
    "hayat", "ilim", "irade", "kudret", "sem", "basar", "kelam",
    # English academic ontology vocabulary
    "entity", "entities", "substance", "essence", "attribute",
    "property", "properties", "classification", "hierarchical",
    "dependency", "dependencies", "constitutive", "accidental",
    "categorical", "categories", "instantiation", "inheritance",
    "subsumption", "identity", "nature", "being", "existence",
    "ontology", "taxonomy", "typology",
})

_MEREOLOJI_TERMS: frozenset = frozenset({
    "part", "whole", "compose", "composed", "component", "element",
    "structure", "mereology", "mereological", "telos", "teleology",
    "teleological", "containment",
    "subpart", "holographic seed", "transparent vessel", "part-whole",
    "bölüm", "parça", "bütün",
    # Theological part-whole vocabulary
    "faculty", "faculties", "phase", "phases", "dimension", "dimensions",
    "coverage", "fragmented", "fragment", "lattice", "hierarchy",
    "külliyat", "cüz", "latife", "unsur", "uzuv",
    "station", "orientation", "landscape",
    # English academic mereology vocabulary
    "composition", "constituent", "constituents", "aggregate",
    "decomposition", "subsystem", "module", "organ", "mechanism",
    "functional unit", "nested", "recursive", "assemblage",
    "integration", "parthood", "overlap",
})

_BAYES_TERMS: frozenset = frozenset({
    "evidence", "probability", "prior", "posterior", "likelihood",
    "bayes", "bayesian", "hypothesis", "confirmation", "update",
    "conditional", "credence", "degree of belief", "probabilistic",
    "delil", "ispat", "burhan",
    # Theological evidence / inference vocabulary
    "hüccet", "emare", "istidlal", "tezahür", "nişane",
    "ihtimal", "şehadet", "alamet", "contingent", "contingency",
    "epistemic", "hermeneutic", "perception",
    # English academic Bayesian/evidential vocabulary
    "inference", "inductive", "abductive", "weight of evidence",
    "falsification", "corroboration", "credibility", "plausibility",
    "statistical", "stochastic", "uncertain", "uncertainty",
    "posterior probability", "prior probability",
})

_OYUN_TERMS: frozenset = frozenset({
    "strategy", "payoff", "equilibrium", "game theory", "cooperation",
    "defection", "nash", "nefs", "rational", "player", "interaction",
    "utility", "dominant", "game model",
    "emmare", "levvame", "mulhime", "mutmainne",
    # English academic game theory vocabulary
    "incentive", "competitive", "collaborative", "adversarial",
    "trade-off", "dilemma", "zero-sum", "pareto",
    "mechanism design", "auction", "bargaining",
    "collective action", "free rider", "commons",
})

_KATEGORI_TERMS: frozenset = frozenset({
    "functor", "morphism", "category", "categorical",
    "natural transformation",
    "commute", "faithful", "isomorphism", "homomorphism",
    "diagram", "arrow", "composition", "functorial",
    "endofunctor",
    # English academic category theory vocabulary
    "universal property", "adjoint", "colimit", "pullback",
    "pushforward", "duality", "equivalence of categories",
    "representable", "presheaf", "monad",
})

_HOLOGRAFIK_TERMS: frozenset = frozenset({
    "holographic", "holografik", "fractal", "self-similar", "seed",
    "besmele", "fatiha", "isomorphism", "compressed", "reflection",
    "part-whole", "celali", "cemali", "hologram",
    "küçük bir kainat",
    # Theological mirroring / projection vocabulary
    "shadow", "mirror", "mirroring", "ayna", "nakış", "nümune",
    "cilve", "zerre", "damla", "encode", "encoding", "projection",
    "isomorphic", "structurally isomorphic",
    # English academic holographic/topological vocabulary
    "microcosm", "macrocosm", "recursive structure",
    "scale invariance", "scale-free", "encapsulation",
    "replication", "replica", "invariant", "topology",
})


def _count_text_matches(text: str, terms: frozenset) -> int:
    """Count unique term matches in text (case-insensitive, word-boundary).

    Uses ``\\b`` at both token start and end to prevent substring false
    positives (e.g. 'part' no longer matches 'department' or 'partly').
    Multi-word phrases like 'holographic seed' still match normally.
    """
    if not text:
        return 0
    text_lower = text.lower()
    return sum(
        1 for t in terms
        if re.search(r'\b' + re.escape(t) + r'\b', text_lower)
    )


def _text_domain_score(
    text: str,
    terms: frozenset,
    *,
    per_match: float = 0.03,
    cap: float = 0.30,
) -> float:
    """Compute domain score from text keyword matches.

    Each unique match adds *per_match*; total capped at *cap*.
    Returns 0.0 when text is empty or no matches found.

    .. deprecated:: Phase 1
        Use the per-lens ``_text_score_*()`` functions instead.
        Retained for backward compatibility.
    """
    matched = _count_text_matches(text, terms)
    if matched == 0:
        return 0.0
    return min(matched * per_match, cap)


# =====================================================================
# Per-Lens Text Scoring (Phase 1 — replaces shared _text_domain_score)
# =====================================================================
# Each scorer uses individualized caps and multi-feature weighting.
# Caps from WATERFALL_PLAN §5.1:
#   Ontoloji=0.65, Mereoloji=0.50, FOL=0.70, Bayes=0.55,
#   OyunTeorisi=0.50, KategoriTeorisi=0.45, Holografik=0.55
# Multi-feature: base term matches + structural bonus for deeper signals.
# KV₇: all are pure functions.

_ESMA_PREFIX_RE = re.compile(
    r"\b(?:el-|er-|es-|eş-|ez-|al-|ar-|as-|aş-|az-)\w+",
    re.IGNORECASE,
)

_PART_WHOLE_RE = re.compile(
    r"\b(?:is\s+part\s+of|consists?\s+of|composed?\s+of|"
    r"contains?|comprises?|subdivided?\s+into|made\s+up\s+of|"
    r"constituted\s+by|broken\s+down\s+into|decomposed?\s+into|"
    r"built\s+from|assembled\s+from|integrated\s+into)\b",
    re.IGNORECASE,
)

_LOGICAL_RE = re.compile(
    r"\b(?:for\s*all|there\s*exists?|implies?|therefore|"
    r"if\s+and\s+only\s+if|iff|necessarily|possibly|"
    r"it\s+follows\s+that|entails?|contradicts?|"
    r"sufficient\s+condition|necessary\s+condition|"
    r"∀|∃|→|⇒|¬|∧|∨|⊥|⊢|⊨)\b",
    re.IGNORECASE,
)

_AX_T_KV_RE = re.compile(r"\bAX\d{1,2}\b|\bT\d{1,2}\b|\bKV[₁₂₃₄₅₆₇₈1-8]\b")

_PROB_QUANTIFIER_RE = re.compile(
    r"\b(?:P\s*\(|Pr\s*\(|probability\s*of|degree\s*of\s*belief|"
    r"given\s+that|conditional\s+on|posterior\s+distribution|"
    r"prior\s+distribution|likelihood\s+ratio|confidence\s+interval)\b",
    re.IGNORECASE,
)

_PAYOFF_MATRIX_RE = re.compile(
    r"\bpayoff\s*matrix|strategy\s*profile|nash\s*equilibrium\b",
    re.IGNORECASE,
)

_NEFS_RE = re.compile(
    r"\b(?:emmare|levvame|mulhime|mutmainne|nefs)\b",
    re.IGNORECASE,
)

_FUNCTOR_ARROW_RE = re.compile(
    r"(?:→|⇒|\bF\s*:\s*\w+\s*→|\bη\s*:)",
    re.IGNORECASE,
)

_BESMELE_DIM_RE = re.compile(r"\bB(?:0[1-9]|1\d|2[0-2])\b")

_HOLOGRAPHIC_SEED_RE = re.compile(
    r"\b(?:holographic\s+seed|part[- ]whole\s+iso(?:morphism)?|"
    r"seed\s+encod(?:e|es|ing)|compressed\s+image|besmele.*fatiha)\b",
    re.IGNORECASE,
)


def _text_score_ontoloji(text: str) -> float:
    """Score text for Ontoloji lens relevance. Cap: 0.65."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _ONTOLOJI_TERMS)
    esma_hits = len(_ESMA_PREFIX_RE.findall(text))
    # base terms: 0.03 each; esma hits: 0.05 each (Names are high-signal)
    raw = base * 0.03 + esma_hits * 0.05
    return min(raw, 0.65)


def _text_score_mereoloji(text: str) -> float:
    """Score text for Mereoloji lens relevance. Cap: 0.50."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _MEREOLOJI_TERMS)
    rel_hits = len(_PART_WHOLE_RE.findall(text))
    # base terms: 0.04 each; relational phrases: 0.06 each
    raw = base * 0.04 + rel_hits * 0.06
    return min(raw, 0.50)


def _text_score_fol(text: str) -> float:
    """Score text for FOL lens relevance. Cap: 0.70."""
    if not text:
        return 0.0
    logic_hits = len(_LOGICAL_RE.findall(text))
    ref_hits = len(_AX_T_KV_RE.findall(text))
    # logical connectives: 0.04 each; axiom/theorem refs: 0.06 each
    raw = logic_hits * 0.04 + ref_hits * 0.06
    return min(raw, 0.70)


def _text_score_bayes(text: str) -> float:
    """Score text for Bayes lens relevance. Cap: 0.55."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _BAYES_TERMS)
    formal_hits = len(_PROB_QUANTIFIER_RE.findall(text))
    # base terms: 0.04 each; formal probability notation: 0.06 each
    raw = base * 0.04 + formal_hits * 0.06
    return min(raw, 0.55)


def _text_score_oyun_teorisi(text: str) -> float:
    """Score text for OyunTeorisi lens relevance. Cap: 0.50."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _OYUN_TERMS)
    matrix_hits = len(_PAYOFF_MATRIX_RE.findall(text))
    nefs_hits = len(_NEFS_RE.findall(text))
    # base terms: 0.04 each; matrix language: 0.06 each; nefs: 0.05 each
    raw = base * 0.04 + matrix_hits * 0.06 + nefs_hits * 0.05
    return min(raw, 0.50)


def _text_score_kategori_teorisi(text: str) -> float:
    """Score text for KategoriTeorisi lens relevance. Cap: 0.45."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _KATEGORI_TERMS)
    arrow_hits = len(_FUNCTOR_ARROW_RE.findall(text))
    # base terms: 0.04 each; arrow/functor notation: 0.06 each
    raw = base * 0.04 + arrow_hits * 0.06
    return min(raw, 0.45)


def _text_score_holografik(text: str) -> float:
    """Score text for Holografik lens relevance. Cap: 0.55."""
    if not text:
        return 0.0
    base = _count_text_matches(text, _HOLOGRAFIK_TERMS)
    dim_hits = len(_BESMELE_DIM_RE.findall(text))
    seed_hits = len(_HOLOGRAPHIC_SEED_RE.findall(text))
    # base terms: 0.04 each; B-dimensions: 0.05 each; seed phrases: 0.06 each
    raw = base * 0.04 + dim_hits * 0.05 + seed_hits * 0.06
    return min(raw, 0.55)


# Dispatch map for per-lens scoring
TEXT_SCORE_MAP: Dict[str, callable] = {
    "Ontoloji": _text_score_ontoloji,
    "Mereoloji": _text_score_mereoloji,
    "FOL": _text_score_fol,
    "Bayes": _text_score_bayes,
    "OyunTeorisi": _text_score_oyun_teorisi,
    "KategoriTeorisi": _text_score_kategori_teorisi,
    "Topoloji + Holografik": _text_score_holografik,
}


def compute_text_scores(text: str) -> Dict[str, float]:
    """Run every TEXT_SCORE_MAP function and return per-lens relevance scores.

    Returns:
        Dict mapping lens string id (e.g. ``"Bayes"``) to a relevance
        score in [0, cap].  Used by ``compute_dynamic_weights()`` in
        ``bileshke/engine.py`` to produce content-adaptive weights.
    """
    return {lid: fn(text) for lid, fn in TEXT_SCORE_MAP.items()}


# =====================================================================
# Lens 1 — Ontoloji (kavram_sozlugu)
# =====================================================================

@trace
def run_ontoloji(params: Dict[str, Any]) -> LensResult:
    """Run the Ontoloji lens (kavram_sozlugu).

    params:
        kavramlar: list[dict]  — optional pre-built Kavram dicts
            Each dict: {"name": str, "hakikat": [{"isim": str, "derece": float}],
                        "mahiyet": str, "dual_class": "harfi"|"ismi",
                        "is_living": bool}

    If kavramlar is empty, runs a default self-check on the registry.
    KV₇: fresh KavramSozlugu per call.
    """
    from kavram_sozlugu.registry import KavramSozlugu
    from kavram_sozlugu.types import Kavram, Tecelli

    registry = KavramSozlugu(preload=True)  # fresh instance

    kavramlar_raw = params.get("kavramlar", [])

    # Tier 2: LLM-provided hints → kavramlar format
    if not kavramlar_raw:
        _hints = params.get("_hints")
        if _hints:
            try:
                for concept in _hints.get("concepts", []):
                    names = concept.get("names_referenced", [])
                    if not names:
                        names = ["el-Hakîm"]
                    hakikat = [{"isim": n, "derece": 0.5} for n in names]
                    kavramlar_raw.append({
                        "name": concept.get("name", "unnamed"),
                        "hakikat": hakikat,
                        "mahiyet": concept.get("mahiyet", ""),
                        "dual_class": concept.get("dual_class", "harfi"),
                        "is_living": concept.get("is_living", False),
                    })
            except Exception:
                pass  # malformed hints → text fallback

    for kd in kavramlar_raw:
        hakikat = tuple(
            Tecelli(isim=t["isim"], derece=t.get("derece", 0.5))
            for t in kd.get("hakikat", [])
        )
        if not hakikat:
            # KV₈: must ground in >= 1 Name — provide minimal default
            hakikat = (Tecelli(isim="el-Hakîm", derece=0.1),)
        k = Kavram(
            name=kd["name"],
            hakikat=hakikat,
            mahiyet=kd.get("mahiyet", ""),
            dual_class=kd.get("dual_class", "harfi"),
            is_living=kd.get("is_living", False),
        )
        registry.register_kavram(k)

    score = clamp_score(registry.yakinlasma())
    checks_total = max(len(registry._kavramlar), 1)
    checks_passed = int(score * checks_total)
    detail = f"Registry size: {len(registry._kavramlar)} kavram(s)"

    # Text-based fallback: scan for known Names + framework vocabulary
    text = params.get("text", "")
    if score == 0.0 and not kavramlar_raw and text:
        known_names = registry.list_isimler()
        text_lower = text.lower()
        name_hits = 0
        for nm in known_names:
            nm_lower = nm.lower()
            if nm_lower in text_lower:
                name_hits += 1
                continue
            # Try without Arabic prefix (el-/er-/es-/eş-/ez-)
            for pfx in ("el-", "er-", "es-", "eş-", "ez-"):
                if nm_lower.startswith(pfx) and nm_lower[len(pfx):] in text_lower:
                    name_hits += 1
                    break
        fw_hits = _count_text_matches(text, _ONTOLOJI_TERMS)
        total_hits = name_hits + fw_hits
        if total_hits > 0:
            # Phase 1: use per-lens scorer (cap=0.65) instead of shared 0.35
            text_score = _text_score_ontoloji(text)
            score = clamp_score(max(text_score, min(total_hits * 0.02, 0.65)))
            checks_total = max(total_hits, 1)
            checks_passed = total_hits
            detail = f"Text scan: {name_hits} Names, {fw_hits} framework terms"

    grade = score_to_grade(score)

    return LensResult(
        lens_id=LensId.ONTOLOJI,
        score=score,
        grade=grade,
        checks_passed=checks_passed,
        checks_total=checks_total,
        detail=detail,
    )


# =====================================================================
# Lens 2 — Mereoloji
# =====================================================================

@trace
def run_mereoloji(params: Dict[str, Any]) -> LensResult:
    """Run the Mereoloji lens.

    params:
        parts: list[dict]  — optional part definitions
            Each dict: {"id": str, "type": str, "telos": str|None}
        relations: list[dict]  — optional relations
            Each dict: {"whole": str, "part": str}

    KV₇: fresh MereologicalStructure per call.
    """
    from mereoloji.relations import MereologicalStructure

    ms = MereologicalStructure()  # fresh instance

    parts_list = list(params.get("parts", []))
    relations_list = list(params.get("relations", []))

    # Tier 2: LLM-provided hints → parts/relations format
    if not parts_list and not relations_list:
        _hints = params.get("_hints")
        if _hints:
            try:
                whole_name = _hints.get("whole", "whole")
                for p in _hints.get("parts", []):
                    name = p if isinstance(p, str) else p.get("name", "part")
                    telos = None if isinstance(p, str) else p.get("telos")
                    parts_list.append({"id": name, "type": "generic", "telos": telos})
                    relations_list.append({"whole": whole_name, "part": name})
                for r in _hints.get("relations", []):
                    if isinstance(r, dict) and "whole" in r and "part" in r:
                        relations_list.append(r)
            except Exception:
                pass  # malformed hints → text fallback

    for p in parts_list:
        ms.add_part(
            part_id=p["id"],
            part_type=p.get("type", "generic"),
            telos=p.get("telos"),
        )
    for r in relations_list:
        ms.add_relation(whole=r["whole"], part=r["part"])

    score = clamp_score(ms.yakinlasma())
    result = ms.verify_all()
    checks_passed = sum(1 for v in result.values() if v)
    checks_total = len(result)
    detail = f"Parts: {len(parts_list)}; checks: {checks_passed}/{checks_total}"

    # Text-based fallback when structure is empty
    text = params.get("text", "")
    has_structure = bool(parts_list) or bool(relations_list)
    if score == 0.0 and not has_structure and text:
        hits = _count_text_matches(text, _MEREOLOJI_TERMS)
        if hits > 0:
            # Phase 1: per-lens scorer (cap=0.50)
            score = clamp_score(_text_score_mereoloji(text))
            checks_passed = hits
            checks_total = max(hits, 1)
            detail = f"Text scan: {hits} mereological terms"

    grade = pass_ratio_to_grade(checks_passed, max(checks_total, 1))

    return LensResult(
        lens_id=LensId.MEREOLOJI,
        score=score,
        grade=grade,
        checks_passed=checks_passed,
        checks_total=checks_total,
        detail=detail,
    )


# =====================================================================
# Lens 3 — FOL (fol_formalizasyon)
# =====================================================================

@trace
def run_fol(params: Dict[str, Any]) -> LensResult:
    """Run the FOL lens.

    params:
        text: str   — source text to scan for AX/T/KV references
        model: dict | None — optional Model for axiom satisfaction checks

    KV₇: pure function, no shared state.
    """
    from fol_formalizasyon import yakinlasma, extract_axiom_refs

    text = params.get("text", "")
    model = params.get("model")  # None is valid

    # Tier 2: LLM-provided hints → enrich text with axiom references
    _hints = params.get("_hints")
    if _hints:
        try:
            extra_refs = _hints.get("axiom_refs", [])
            premises = _hints.get("premises", [])
            conclusion = _hints.get("conclusion", "")
            supplement_parts = []
            if extra_refs:
                supplement_parts.append(" ".join(str(r) for r in extra_refs))
            if premises:
                supplement_parts.append(" ".join(str(p) for p in premises))
            if conclusion:
                supplement_parts.append(f"Therefore: {conclusion}")
            if supplement_parts:
                text = f"{text}\n{' '.join(supplement_parts)}"
        except Exception:
            pass  # malformed hints → use original text

    score = clamp_score(yakinlasma(text, model))
    refs = extract_axiom_refs(text)
    total_refs = sum(len(v) for v in refs.values())

    grade = score_to_grade(score)

    return LensResult(
        lens_id=LensId.FOL,
        score=score,
        grade=grade,
        checks_passed=total_refs,
        checks_total=max(total_refs, 1),
        detail=f"AX refs: {len(refs.get('axioms', []))}; T refs: {len(refs.get('theorems', []))}; KV refs: {len(refs.get('kavaid', []))}",
    )


# =====================================================================
# Lens 4 — Bayes (bayes_analiz)
# =====================================================================

@trace
def run_bayes(params: Dict[str, Any]) -> LensResult:
    """Run the Bayes lens.

    params:
        model: dict | None — serialised BayesModel, or None for default check.

    KV₇: fresh computation, no shared state.
    """
    from bayes_analiz import yakinlasma

    model = params.get("model")  # None → score 0.0 (expected)

    # Tier 2: LLM-provided hints → BayesModel
    if model is None:
        _hints = params.get("_hints")
        if _hints:
            try:
                from bayes_analiz.types import (
                    BayesModel, Hypothesis, Evidence,
                )
                bm = BayesModel(name="hint-derived")
                for h in _hints.get("hypotheses", []):
                    bm.add_hypothesis(Hypothesis(
                        name=h.get("name", "H"),
                        prior=max(0.01, min(0.99, h.get("prior", 0.5))),
                    ))
                for e in _hints.get("evidence", []):
                    bm.add_evidence(Evidence(
                        name=e.get("name", "E"),
                        likelihood=max(0.01, min(0.99,
                            e.get("likelihood", 0.5))),
                        likelihood_complement=max(0.01, min(0.99,
                            e.get("likelihood_complement", 0.5))),
                    ))
                for h_name in list(bm.hypotheses.keys()):
                    for e_name in list(bm.evidence_items.keys()):
                        try:
                            bm.update(h_name, e_name)
                        except Exception:
                            pass
                model = bm
            except Exception:
                pass  # malformed hints → text fallback

    score = clamp_score(yakinlasma(model))

    # Text-based fallback when no model provided
    text = params.get("text", "")
    if score == 0.0 and model is None and text:
        # Phase 1: per-lens scorer (cap=0.55)
        text_score = _text_score_bayes(text)
        if text_score > 0:
            score = clamp_score(text_score)

    grade = score_to_grade(score)
    hits = _count_text_matches(text, _BAYES_TERMS) if score > 0 and model is None else 0

    return LensResult(
        lens_id=LensId.BAYES,
        score=score,
        grade=grade,
        checks_passed=max(1, hits) if score > 0 else 0,
        checks_total=max(1, hits) if score > 0 else 1,
        detail=f"Bayes yakinlasma: {score:.4f}" + (f" (text: {hits} terms)" if hits else ""),
    )


# =====================================================================
# Lens 5 — OyunTeorisi (oyun_teorisi)
# =====================================================================

@trace
def run_oyun_teorisi(params: Dict[str, Any]) -> LensResult:
    """Run the OyunTeorisi lens.

    params:
        model: dict | None — serialised GameModel, or None for default check.

    KV₇: fresh computation, no shared state.
    """
    from oyun_teorisi import yakinlasma

    model = params.get("model")  # None → score 0.0

    # Tier 2: LLM-provided hints → GameModel
    if model is None:
        _hints = params.get("_hints")
        if _hints:
            try:
                from oyun_teorisi.types import (
                    GameModel, Player, Strategy, PayoffEntry,
                    NefsMakam, StrategyType,
                )
                _makam_map = {
                    "emmare": NefsMakam.EMMARE,
                    "levvame": NefsMakam.LEVVAME,
                    "mulhime": NefsMakam.MULHIME,
                    "mutmainne": NefsMakam.MUTMAINNE,
                }
                _stype_map = {
                    "worldly": StrategyType.WORLDLY,
                    "alignment": StrategyType.ALIGNMENT,
                    "mixed": StrategyType.MIXED,
                }
                gm = GameModel(name="hint-derived")
                for p in _hints.get("players", []):
                    gm.add_player(Player(
                        name=p.get("name", "player"),
                        makam=_makam_map.get(
                            p.get("makam", "emmare").lower(),
                            NefsMakam.EMMARE),
                    ))
                for s in _hints.get("strategies", []):
                    player_name = s.get("player", "")
                    if not player_name and gm.players:
                        player_name = next(iter(gm.players))
                    gm.add_strategy(player_name, Strategy(
                        name=s.get("name", "strategy"),
                        strategy_type=_stype_map.get(
                            s.get("type", "mixed").lower(),
                            StrategyType.MIXED),
                    ))
                for po in _hints.get("payoffs", []):
                    p_name = po.get("player", "player")
                    player_obj = gm.players.get(p_name)
                    makam = player_obj.makam if player_obj else NefsMakam.EMMARE
                    gm.add_payoff(PayoffEntry(
                        player_name=p_name,
                        makam=makam,
                        my_strategy=po.get("my_strategy", ""),
                        opponent_strategy=po.get("opponent_strategy", ""),
                        payoff=float(po.get("payoff", 0.0)),
                    ))
                model = gm
            except Exception:
                pass  # malformed hints → text fallback

    score = clamp_score(yakinlasma(model))

    # Text-based fallback when no model provided
    text = params.get("text", "")
    if score == 0.0 and model is None and text:
        # Phase 1: per-lens scorer (cap=0.50)
        text_score = _text_score_oyun_teorisi(text)
        if text_score > 0:
            score = clamp_score(text_score)

    grade = score_to_grade(score)
    hits = _count_text_matches(text, _OYUN_TERMS) if score > 0 and model is None else 0

    return LensResult(
        lens_id=LensId.OYUN_TEORISI,
        score=score,
        grade=grade,
        checks_passed=max(1, hits) if score > 0 else 0,
        checks_total=max(1, hits) if score > 0 else 1,
        detail=f"OyunTeorisi yakinlasma: {score:.4f}" + (f" (text: {hits} terms)" if hits else ""),
    )


# =====================================================================
# Lens 6 — KategoriTeorisi (kategori_teorisi)
# =====================================================================

@trace
def run_kategori_teorisi(params: Dict[str, Any]) -> LensResult:
    """Run the KategoriTeorisi lens.

    params:
        cat: dict | None     — serialised Category
        functor: dict | None — serialised Functor
        eta: dict | None     — serialised NaturalTransformation

    KV₇: fresh computation, no shared state.
    """
    from kategori_teorisi import yakinlasma

    cat = params.get("cat")
    functor = params.get("functor")
    eta = params.get("eta")

    # Tier 2: LLM-provided hints → Category
    if cat is None and functor is None:
        _hints = params.get("_hints")
        if _hints:
            try:
                from kategori_teorisi.types import (
                    Category, CatObject, Morphism,
                )
                c = Category(name=_hints.get("category_name", "C"))
                obj_map: Dict[str, Any] = {}
                for obj_name in _hints.get("objects", []):
                    obj = CatObject(name=str(obj_name))
                    c.add_object(obj)
                    obj_map[str(obj_name)] = obj
                for m in _hints.get("morphisms", []):
                    src = obj_map.get(m.get("source", ""))
                    tgt = obj_map.get(m.get("target", ""))
                    if src and tgt:
                        c.add_morphism(Morphism(
                            name=m.get("name", f"{src.name}->{tgt.name}"),
                            source=src,
                            target=tgt,
                        ))
                cat = c
            except Exception:
                pass  # malformed hints → text fallback

    score = clamp_score(yakinlasma(cat, functor, eta))

    # Text-based fallback when no category data provided
    text = params.get("text", "")
    if score == 0.0 and cat is None and functor is None and text:
        # Phase 1: per-lens scorer (cap=0.45)
        text_score = _text_score_kategori_teorisi(text)
        if text_score > 0:
            score = clamp_score(text_score)

    grade = score_to_grade(score)
    hits = _count_text_matches(text, _KATEGORI_TERMS) if score > 0 and cat is None else 0

    return LensResult(
        lens_id=LensId.KATEGORI_TEORISI,
        score=score,
        grade=grade,
        checks_passed=max(1, hits) if score > 0 else 0,
        checks_total=max(1, hits) if score > 0 else 1,
        detail=f"KategoriTeorisi yakinlasma: {score:.4f}" + (f" (text: {hits} terms)" if hits else ""),
    )


# =====================================================================
# Lens 7 — Topoloji + Holografik (holografik)
# =====================================================================

@trace
def run_holografik(params: Dict[str, Any]) -> LensResult:
    """Run the Holografik lens.

    params:
        model: dict | None — serialised HolographicModel, or None for default.

    KV₇: fresh computation, no shared state.
    """
    from holografik import yakinlasma

    model = params.get("model")  # None → score 0.0

    # Tier 2: LLM-provided hints → HolographicModel
    if model is None:
        _hints = params.get("_hints")
        if _hints:
            try:
                from holografik.types import (
                    HolographicModel, HolographicUnit,
                    SeedVector, FidelityPair,
                )
                hm = HolographicModel(name="hint-derived")
                dims_active = _hints.get("dimensions_active", [])
                for desc in _hints.get("seed_descriptions", []):
                    name = desc.get("name", "unit") if isinstance(desc, dict) else str(desc)
                    level = desc.get("level", "") if isinstance(desc, dict) else ""
                    vals = [0.0] * 22
                    for d in dims_active:
                        if isinstance(d, int) and 0 <= d < 22:
                            vals[d] = 0.5
                    seed = (
                        SeedVector(values=tuple(vals))
                        if any(v > 0 for v in vals)
                        else SeedVector.uniform(0.3)
                    )
                    hm.add_unit(HolographicUnit(
                        name=name, seed=seed, level=level))
                for fp in _hints.get("fidelity_pairs", []):
                    fidelity = max(0.01, min(0.99,
                        float(fp.get("fidelity", 0.5))))
                    hm.add_fidelity_pair(FidelityPair(
                        source_name=fp.get("source", ""),
                        derivative_name=fp.get("derivative", ""),
                        fidelity=fidelity,
                    ))
                model = hm
            except Exception:
                pass  # malformed hints → text fallback

    score = clamp_score(yakinlasma(model))

    # Text-based fallback when no model provided
    text = params.get("text", "")
    if score == 0.0 and model is None and text:
        # Phase 1: per-lens scorer (cap=0.55)
        text_score = _text_score_holografik(text)
        if text_score > 0:
            score = clamp_score(text_score)

    grade = score_to_grade(score)
    hits = _count_text_matches(text, _HOLOGRAFIK_TERMS) if score > 0 and model is None else 0

    return LensResult(
        lens_id=LensId.TOPOLOJI_HOLOGRAFIK,
        score=score,
        grade=grade,
        checks_passed=max(1, hits) if score > 0 else 0,
        checks_total=max(1, hits) if score > 0 else 1,
        detail=f"Holografik yakinlasma: {score:.4f}" + (f" (text: {hits} terms)" if hits else ""),
    )


# =====================================================================
# Dispatch map: lens_id string → adapter function
# =====================================================================

ADAPTER_MAP: Dict[str, Callable[[Dict[str, Any]], LensResult]] = {
    "Ontoloji": run_ontoloji,
    "Mereoloji": run_mereoloji,
    "FOL": run_fol,
    "Bayes": run_bayes,
    "OyunTeorisi": run_oyun_teorisi,
    "KategoriTeorisi": run_kategori_teorisi,
    "Topoloji + Holografik": run_holografik,
}


@trace
def run_lens(lens_id: str, params: Optional[Dict[str, Any]] = None) -> LensResult:
    """Execute a single lens by ID.

    Args:
        lens_id: One of the 7 valid lens ID strings.
        params: Lens-specific parameters (default: empty dict).

    Returns:
        LensResult with score, grade, and checks.

    Raises:
        ValueError: If lens_id is not recognised.
    """
    if params is None:
        params = {}
    adapter = ADAPTER_MAP.get(lens_id)
    if adapter is None:
        raise ValueError(
            f"Unknown lens_id: {lens_id!r}. "
            f"Valid IDs: {list(ADAPTER_MAP.keys())}"
        )
    return adapter(params)
