"""5S battery — Syntax / Semantics / Syllogism / Synthesis / Semiotics.

Phase 1a (landed in ticket #000021): Syntax + Semantics over the
existing π* registry.

Phase 1b (ticket #000023, this file): Syllogism, Synthesis,
Semiotics — all carrier-aware (text / claim_lattice / prose carriers
in v1; future-carrier hooks via fixture metadata that v1 rejects
explicitly).

No LLM-as-judge anywhere. All runners deterministic.
"""

from __future__ import annotations

from pathlib import Path

from bench.batteries.base import (
    BatteryResult,
    TaskResult,
    fixture_digest,
    fixture_meta,
    iter_tasks,
    validate_carrier,
)


def _runtime_digest() -> str:
    """SHA-256 fingerprint of the active π* registry.

    Includes name@version of every registered π* so a registry change
    surfaces in the runtime_digest.
    """
    import hashlib

    from arborist.pi_star import list_keys

    payload = "\n".join(list_keys()).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def _carrier_check(task: dict) -> tuple[bool, str]:
    """Return (ok, reason). ok=False means task fails immediately."""
    reason = validate_carrier(task)
    if reason is not None:
        return False, reason
    return True, ""


# ---------------------------------------------------------------------
# Phase 1a: Syntax + Semantics (landed in ticket #000021)
# ---------------------------------------------------------------------


def run_syntax(fixtures_path: Path) -> BatteryResult:
    """Each task asserts that the named π* canonicalizes the input
    without raising. expected="pass" is the only supported expected;
    other values fail loudly."""
    from arborist.pi_star import get

    per_task: list[TaskResult] = []
    pass_count = 0
    fail_count = 0
    for task in iter_tasks(fixtures_path):
        task_id = task["id"]
        ok, reason = _carrier_check(task)
        if not ok:
            per_task.append(
                TaskResult(task_id=task_id, passed=False, detail={"reason": reason})
            )
            fail_count += 1
            continue
        detail: dict = {}
        try:
            pi_star = get(task.get("pi_star_ref", task.get("pi_star")))
            pi_star.canonicalize(task["input"].encode("utf-8"))
            ok = task.get("expected", "pass") == "pass"
            if not ok:
                detail["reason"] = "canonicalize succeeded but expected != pass"
        except NotImplementedError:
            detail["reason"] = "pi_star is a stub"
            ok = False
        except Exception as exc:  # noqa: BLE001
            detail["reason"] = f"{type(exc).__name__}: {exc}"
            ok = False
        if ok:
            pass_count += 1
        else:
            fail_count += 1
        per_task.append(TaskResult(task_id=task_id, passed=ok, detail=detail))

    total = pass_count + fail_count
    pass_rate = pass_count / total if total else 0.0
    meta = fixture_meta(fixtures_path)
    return BatteryResult(
        battery="5s",
        sub_battery=meta.get("sub_battery", "syntax"),
        fixture_path=str(fixtures_path),
        fixture_digest=fixture_digest(fixtures_path),
        pass_count=pass_count,
        fail_count=fail_count,
        metrics={"parse_pass_rate": pass_rate},
        per_task=per_task,
        runtime_digest=_runtime_digest(),
    )


def run_semantics(fixtures_path: Path) -> BatteryResult:
    """Each task: two inputs + a π*; passes if canonicalize agrees with
    expected_equivalent."""
    from arborist.pi_star import get

    per_task: list[TaskResult] = []
    pass_count = 0
    fail_count = 0
    for task in iter_tasks(fixtures_path):
        task_id = task["id"]
        ok, reason = _carrier_check(task)
        if not ok:
            per_task.append(
                TaskResult(task_id=task_id, passed=False, detail={"reason": reason})
            )
            fail_count += 1
            continue
        detail: dict = {}
        try:
            pi_star = get(task.get("pi_star_ref", task.get("pi_star")))
            ca = pi_star.canonicalize(task["input_a"].encode("utf-8"))
            cb = pi_star.canonicalize(task["input_b"].encode("utf-8"))
            equivalent = ca == cb
            ok = equivalent == bool(task["expected_equivalent"])
            if not ok:
                detail["reason"] = (
                    f"observed_equivalent={equivalent} "
                    f"expected={task['expected_equivalent']}"
                )
        except Exception as exc:  # noqa: BLE001
            detail["reason"] = f"{type(exc).__name__}: {exc}"
            ok = False
        if ok:
            pass_count += 1
        else:
            fail_count += 1
        per_task.append(TaskResult(task_id=task_id, passed=ok, detail=detail))

    total = pass_count + fail_count
    rate = pass_count / total if total else 0.0
    meta = fixture_meta(fixtures_path)
    return BatteryResult(
        battery="5s",
        sub_battery=meta.get("sub_battery", "semantics"),
        fixture_path=str(fixtures_path),
        fixture_digest=fixture_digest(fixtures_path),
        pass_count=pass_count,
        fail_count=fail_count,
        metrics={"equivalence_recovery_rate": rate},
        per_task=per_task,
        runtime_digest=_runtime_digest(),
    )


# ---------------------------------------------------------------------
# Phase 1b: Syllogism (ticket #000023)
# ---------------------------------------------------------------------

# Whitelist of syllogistic rules the deterministic runner can license.
# Each rule has a small kernel that returns (valid: bool, reason: str).
def _syllogism_rule_categorical_transitivity(
    premises: list[dict], candidate: dict
) -> tuple[bool, str]:
    """All A are B; All B are C ⇒ All A are C.

    Premise text shape: ``All <X> are <Y>.`` (also accepts "is").
    Order-agnostic: premises may appear in either chain order so a
    natural English ordering ("All mammals are animals; All dogs are
    mammals") and the textbook ordering ("All dogs are mammals; All
    mammals are animals") both validate.
    """
    if len(premises) != 2:
        return False, "categorical_transitivity needs exactly 2 premises"
    p1 = _parse_categorical(premises[0]["claim"])
    p2 = _parse_categorical(premises[1]["claim"])
    c = _parse_categorical(candidate["claim"])
    if p1 is None or p2 is None or c is None:
        return False, "claim does not match 'All X are Y.' pattern"
    # Try both orderings so fixture authors can write premises in
    # natural English order (general first) or textbook order
    # (specific first).
    for first, second in [(p1, p2), (p2, p1)]:
        if first[1] == second[0]:
            if c[0] == first[0] and c[1] == second[1]:
                return True, ""
            return False, "candidate endpoints do not match chain"
    return False, f"premises do not chain: {p1} vs {p2}"


def _parse_categorical(text: str) -> tuple[str, str] | None:
    """Parse 'All <X> are <Y>.' or 'All <X> is <Y>.' into (X, Y)."""
    s = text.strip().rstrip(".")
    lower = s.lower()
    if not lower.startswith("all "):
        return None
    rest = s[4:]
    for connector in (" are ", " is "):
        parts = rest.split(connector, 1)
        if len(parts) == 2:
            return parts[0].strip(), parts[1].strip()
    return None


def _syllogism_rule_invalid_converse(
    premises: list[dict], candidate: dict
) -> tuple[bool, str]:
    """Candidate is the converse of a premise. Must FAIL.

    A fixture tagged ``rule="invalid_converse"`` with ``expected="fail"``
    asserts that the runner correctly identifies an invalid step.
    """
    if len(premises) < 1:
        return False, "invalid_converse needs at least 1 premise"
    c = _parse_categorical(candidate["claim"])
    if c is None:
        return False, "candidate not a categorical claim"
    for prem in premises:
        p = _parse_categorical(prem["claim"])
        if p is None:
            continue
        if p[0] == c[1] and p[1] == c[0]:
            # Converse detected — invalid step.
            return False, "invalid converse of premise"
    # Not actually a converse — the fixture is mis-tagged.
    return False, "no converse pair detected; fixture mis-tagged"


def _syllogism_rule_missing_premise(
    premises: list[dict], candidate: dict
) -> tuple[bool, str]:
    """Candidate references evidence not present in premises. Must FAIL."""
    available_flat: set[str] = set()
    for p in premises:
        for i in p.get("evidence_ids", []):
            available_flat.add(i)
    used = set(candidate.get("uses", []))
    missing = used - available_flat
    if missing:
        return False, f"candidate references missing evidence ids: {sorted(missing)}"
    return True, "all evidence present"


def _syllogism_rule_chain_3(
    premises: list[dict], candidate: dict
) -> tuple[bool, str]:
    """Three-step transitive chain: A→B, B→C, C→D ⇒ A→D.

    Order-agnostic over premise permutations.
    """
    from itertools import permutations

    if len(premises) != 3:
        return False, "chain_3 needs exactly 3 premises"
    parsed = [_parse_categorical(p["claim"]) for p in premises]
    c = _parse_categorical(candidate["claim"])
    if any(x is None for x in parsed) or c is None:
        return False, "claim parse failure"
    for ordering in permutations(parsed):
        a, b, d = ordering
        if a[1] == b[0] and b[1] == d[0]:
            if c[0] == a[0] and c[1] == d[1]:
                return True, ""
    return False, "no valid chain ordering matches candidate endpoints"


_SYLLOGISM_RULES = {
    "categorical_transitivity": _syllogism_rule_categorical_transitivity,
    "invalid_converse": _syllogism_rule_invalid_converse,
    "missing_premise": _syllogism_rule_missing_premise,
    "chain_3": _syllogism_rule_chain_3,
}


def run_syllogism(fixtures_path: Path) -> BatteryResult:
    """Stepwise entailment validity. Deterministic, no LLM.

    Each task names a ``rule`` from :data:`_SYLLOGISM_RULES`. The
    runner applies the rule's kernel; the task passes if the kernel's
    verdict matches ``expected`` (``"pass"`` ⇒ valid step;
    ``"fail"`` ⇒ invalid step correctly identified).
    """
    per_task: list[TaskResult] = []
    pass_count = 0
    fail_count = 0
    for task in iter_tasks(fixtures_path):
        task_id = task["id"]
        ok, reason = _carrier_check(task)
        if not ok:
            per_task.append(
                TaskResult(task_id=task_id, passed=False, detail={"reason": reason})
            )
            fail_count += 1
            continue
        rule = task.get("rule")
        kernel = _SYLLOGISM_RULES.get(rule)
        if kernel is None:
            per_task.append(
                TaskResult(
                    task_id=task_id,
                    passed=False,
                    detail={"reason": f"unknown_rule: {rule!r}"},
                )
            )
            fail_count += 1
            continue
        expected = task.get("expected", "pass")
        valid, why = kernel(task["premises"], task["candidate_step"])
        observed = "pass" if valid else "fail"
        passed = observed == expected
        detail = {
            "rule": rule,
            "expected": expected,
            "observed": observed,
            "kernel_reason": why,
        }
        per_task.append(TaskResult(task_id=task_id, passed=passed, detail=detail))
        if passed:
            pass_count += 1
        else:
            fail_count += 1

    total = pass_count + fail_count
    rate = pass_count / total if total else 0.0
    meta = fixture_meta(fixtures_path)
    return BatteryResult(
        battery="5s",
        sub_battery=meta.get("sub_battery", "syllogism"),
        fixture_path=str(fixtures_path),
        fixture_digest=fixture_digest(fixtures_path),
        pass_count=pass_count,
        fail_count=fail_count,
        metrics={"step_validity_rate": rate},
        per_task=per_task,
        runtime_digest=_runtime_digest(),
    )


# ---------------------------------------------------------------------
# Phase 1b: Synthesis (ticket #000023)
# ---------------------------------------------------------------------


def _synthesis_check(task: dict) -> tuple[bool, str]:
    """Validate that the expected_derivation is supported by the fact_set.

    Each derivation step lists ``uses`` (fact IDs from the fact_set).
    A derivation is valid iff every ``uses`` ID exists in the
    fact_set AND each derivation claim's text appears as a substring
    of at least one cited fact's text (lower-cased, whitespace-
    normalized). This is structural support, not semantic entailment;
    enough to drive Phase 1b without an LLM judge.
    """
    facts = {f["id"]: f["text"] for f in task.get("fact_set", [])}
    derivation = task.get("expected_derivation", [])
    for step in derivation:
        uses = step.get("uses", [])
        for fid in uses:
            if fid not in facts:
                return False, f"derivation step uses unknown fact id {fid!r}"
        claim_text = _normalize(step["claim"])
        any_support = False
        for fid in uses:
            fact_text = _normalize(facts[fid])
            if claim_text in fact_text or _token_subset(claim_text, fact_text):
                any_support = True
                break
        if not any_support:
            return False, f"step {step['claim']!r} not supported by cited facts"
    return True, "all steps supported"


_STOPWORDS = frozenset({
    "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
    "of", "on", "in", "to", "for", "with", "by", "and", "or", "as",
    "that", "which", "who", "whom", "whose", "this", "these", "those",
    "has", "have", "had", "do", "does", "did", "from", "at", "into",
    "over", "under", "above", "below", "per",
})


def _normalize(s: str) -> str:
    return " ".join(s.lower().strip().split())


def _content_tokens(s: str) -> set[str]:
    """Lower-cased tokens with stopwords removed, punctuation stripped."""
    out = set()
    for tok in s.lower().split():
        clean = tok.strip(".,;:!?\"'()[]{}")
        if clean and clean not in _STOPWORDS:
            out.add(clean)
    return out


def _token_subset(a: str, b: str) -> bool:
    """True if every content token in a appears in b.

    Stricter than the previous overlap-percentage check — catches
    single-token entity swaps (e.g., 'K2' vs 'Mount Everest').
    """
    a_tokens = _content_tokens(a)
    b_tokens = _content_tokens(b)
    if not a_tokens:
        return False
    return a_tokens.issubset(b_tokens)


def run_synthesis(fixtures_path: Path) -> BatteryResult:
    """Goal + fact_set + expected_derivation → pass/fail.

    Pure structural check, no LLM, no retrieval. Phase 1b stays
    deterministic.
    """
    per_task: list[TaskResult] = []
    pass_count = 0
    fail_count = 0
    for task in iter_tasks(fixtures_path):
        task_id = task["id"]
        ok, reason = _carrier_check(task)
        if not ok:
            per_task.append(
                TaskResult(task_id=task_id, passed=False, detail={"reason": reason})
            )
            fail_count += 1
            continue
        expected = task.get("expected", "pass")
        valid, why = _synthesis_check(task)
        observed = "pass" if valid else "fail"
        passed = observed == expected
        detail = {
            "expected": expected,
            "observed": observed,
            "reason": why,
        }
        per_task.append(TaskResult(task_id=task_id, passed=passed, detail=detail))
        if passed:
            pass_count += 1
        else:
            fail_count += 1

    total = pass_count + fail_count
    rate = pass_count / total if total else 0.0
    meta = fixture_meta(fixtures_path)
    return BatteryResult(
        battery="5s",
        sub_battery=meta.get("sub_battery", "synthesis"),
        fixture_path=str(fixtures_path),
        fixture_digest=fixture_digest(fixtures_path),
        pass_count=pass_count,
        fail_count=fail_count,
        metrics={"derivation_pass_rate": rate},
        per_task=per_task,
        runtime_digest=_runtime_digest(),
    )


# ---------------------------------------------------------------------
# Phase 1b: Semiotics (ticket #000023)
# ---------------------------------------------------------------------


def _semiotic_swap_check(task: dict) -> tuple[bool, str]:
    """Apply ``swap.from`` → ``swap.to`` in source_text; canonicalize
    both via the named π*; compare.

    For a synonym swap with ``expected_invariant=True``: the canonical
    forms should differ ONLY in the swapped token (the rest is
    identical). For a non-equivalent swap with
    ``expected_invariant=False``: canonical forms should differ
    semantically (we approximate this as: the swapped strings remain
    different after canonicalization, AND the invariant_fields would
    not all match — Phase 1b only checks the surface canonicalization).
    """
    from arborist.pi_star import get

    pi_star = get(task["pi_star_ref"])
    src = task["source_text"].encode("utf-8")
    swap = task["swap"]
    swapped = task["source_text"].replace(swap["from"], swap["to"]).encode("utf-8")
    canon_src = pi_star.canonicalize(src)
    canon_swapped = pi_star.canonicalize(swapped)

    if task["expected_invariant"]:
        # For Phase 1b: a synonym swap should yield a canonical-form
        # change limited to the swap. We assert the two forms differ
        # by exactly the swap (the canonicalizer should preserve the
        # rest of the prose). The structural check is: every word in
        # canon_src that isn't in `swap.from` survives in canon_swapped.
        return _swap_preserves_invariant(
            canon_src, canon_swapped, swap["from"], swap["to"]
        )
    # expected_invariant=False: a non-synonym swap should produce
    # canonical forms that differ in ways NOT explainable solely by
    # the swap. The simplest deterministic check: swap was applied
    # and canonical forms are not byte-equal (they wouldn't be — the
    # swap text is different). We assert the swap WAS detected as
    # non-trivial — i.e., the canonical forms DIFFER even after
    # accounting for the swap text. A correct non-equivalent fixture
    # has distinct canonical "essence" beyond the surface swap.
    return _swap_changes_essence(
        canon_src, canon_swapped, swap["from"], swap["to"]
    )


def _swap_preserves_invariant(
    canon_src: bytes, canon_swapped: bytes, frm: str, to: str
) -> tuple[bool, str]:
    """Does the swap produce only the expected substitution?"""
    # Re-substitute in canonical form: replacing `to` with `frm` in
    # canon_swapped should reproduce canon_src.
    rebuilt = canon_swapped.decode("utf-8").replace(to, frm).encode("utf-8")
    if rebuilt == canon_src:
        return True, "synonym swap preserved invariant"
    return False, "swap changed more than the substituted token"


def _swap_changes_essence(
    canon_src: bytes, canon_swapped: bytes, frm: str, to: str
) -> tuple[bool, str]:
    """Does the swap change more than just the surface token?

    Re-substituting `to` → `frm` should NOT yield canon_src for a
    non-synonym swap (the meaning shift surfaces somewhere). Phase
    1b uses the cheap surface heuristic: forms differ even after
    re-substitution.
    """
    rebuilt = canon_swapped.decode("utf-8").replace(to, frm).encode("utf-8")
    if rebuilt != canon_src:
        return True, "non-equivalent swap detected"
    return False, "swap was actually a synonym; fixture mis-tagged"


def run_semiotics(fixtures_path: Path) -> BatteryResult:
    """Invariance under controlled sign substitution.

    v1 fixtures use ``concept_relations.relation_kind='synonym'`` for
    positives and explicit non-synonyms for negatives. Hidden-channel
    / steganography fixtures are reserved for Phase 2 — defensive
    detection only, never generation.
    """
    per_task: list[TaskResult] = []
    pass_count = 0
    fail_count = 0
    for task in iter_tasks(fixtures_path):
        task_id = task["id"]
        ok, reason = _carrier_check(task)
        if not ok:
            per_task.append(
                TaskResult(task_id=task_id, passed=False, detail={"reason": reason})
            )
            fail_count += 1
            continue
        try:
            valid, why = _semiotic_swap_check(task)
            expected = task["expected_invariant"]
            observed = valid
            passed = observed == expected
        except Exception as exc:  # noqa: BLE001
            valid = False
            why = f"{type(exc).__name__}: {exc}"
            passed = False

        detail = {
            "expected_invariant": task["expected_invariant"],
            "observed_invariant": valid,
            "reason": why,
            "relation_kind": task.get("swap", {}).get("relation_kind"),
        }
        per_task.append(TaskResult(task_id=task_id, passed=passed, detail=detail))
        if passed:
            pass_count += 1
        else:
            fail_count += 1

    total = pass_count + fail_count
    rate = pass_count / total if total else 0.0
    meta = fixture_meta(fixtures_path)
    return BatteryResult(
        battery="5s",
        sub_battery=meta.get("sub_battery", "semiotics"),
        fixture_path=str(fixtures_path),
        fixture_digest=fixture_digest(fixtures_path),
        pass_count=pass_count,
        fail_count=fail_count,
        metrics={"invariance_under_swap": rate},
        per_task=per_task,
        runtime_digest=_runtime_digest(),
    )


SUB_BATTERIES = {
    "syntax": run_syntax,
    "semantics": run_semantics,
    "syllogism": run_syllogism,
    "synthesis": run_synthesis,
    "semiotics": run_semiotics,
}
