"""
Deterministic math verification using SymPy.

This module provides a SymPy-based verification engine for educational math
content. It works in two steps:

1. LLM extraction: An LLM (configured via 'math_extractor' task in the registry)
   parses natural-language educational content into a structured MathExtraction
   object.
2. SymPy verification: The structured data is verified deterministically using
   symbolic math -- no LLM reasoning involved.

The result is injected into the evaluator's system prompt as authoritative
ground truth via the _get_programmatic_context() hook.

Architecture follows the same pattern as QuizEvaluator's chi-square answer
balance: programmatic result -> formatted context -> prepended to system prompt.
"""

import asyncio
import json
import logging
import os
import random
import re
import statistics as pystats
from math import gcd as math_gcd
from pathlib import Path
from typing import List, Optional

import sympy  # noqa: F401 – used for type annotations and dynamic eval
from sympy import (
    Abs,
    Eq,
    Float,
    Rational,
    S,
    Symbol,
    cbrt,
    isprime,
    lcm,
    oo,
    pi,
    simplify,
    solve,
    sqrt,
    sympify,
)
from sympy.ntheory import divisors

from inceptbench_new.models.math_verification import (
    MathExtraction,
    MathVerificationResult,
    OptionAnalysis,
)
from inceptbench_new.llm.base import LLMMessage
from inceptbench_new.llm.factory import LLMFactory
from inceptbench_new.utils.failure_tracker import AttemptError, FailureTracker

logger = logging.getLogger(__name__)

# Retry configuration (mirrors QuizEvaluator._calculate_answer_balance)
MAX_RETRIES = 3
BASE_DELAY = 4.0

# Numeric tolerance for approximate comparisons (covers pi-approximation
# rounding and similar K-12 rounding conventions).  0.1 % is tight enough to
# never conflate genuinely wrong K-12 answers (which differ by ≥ 5 %).
NUMERIC_RELATIVE_TOLERANCE = 0.001

# Math-detection keywords for the lightweight gate
_MATH_KEYWORDS = re.compile(
    r"""
    \b(
        # Operations
        add|subtract|multiply|divide|sum|difference|product|quotient|
        # Arithmetic terms
        equals|equal|total|remainder|calculate|compute|evaluate|simplify|
        # Fractions / decimals / percent
        fraction|denominator|numerator|decimal|percent|percentage|ratio|proportion|
        # Algebra
        solve|equation|variable|expression|inequality|slope|intercept|function|
        # Geometry
        area|perimeter|volume|surface\s+area|circumference|radius|diameter|
        triangle|rectangle|circle|square|polygon|angle|parallel|perpendicular|
        congruent|similar|hypotenuse|pythagorean|
        # Statistics
        mean|median|mode|range|average|probability|
        # Number theory
        prime|composite|factor|multiple|divisible|divisor|GCF|LCM|GCD|
        # Measurement
        inch|feet|foot|yard|mile|meter|centimeter|kilometer|
        gallon|quart|pint|liter|milliliter|
        pound|ounce|gram|kilogram|
        # Math symbols embedded in text
        ×|÷|±|√|π
    )\b
    """,
    re.IGNORECASE | re.VERBOSE,
)

_MATH_PATTERNS = re.compile(
    r"""
    # Numeric expressions with operators
    \d+\s*[+\-*/×÷]\s*\d+ |
    # Fractions like 3/4
    \d+\s*/\s*\d+ |
    # Equations like x = 5 or 2x + 3
    [a-zA-Z]\s*[=<>] |
    \d+\s*[a-zA-Z]\s*[+\-] |
    # Exponents
    \d+\s*\^\s*\d+ |
    # Percentages
    \d+\s*%
    """,
    re.VERBOSE,
)


def _has_math_content(content: str) -> bool:
    """
    Lightweight check for whether content likely contains verifiable math.

    Uses regex/keyword matching -- fast enough to run on every evaluation.
    False positives are acceptable (they just trigger an LLM extraction that
    returns has_math=false). False negatives are what we want to minimize.
    """
    if _MATH_KEYWORDS.search(content):
        return True
    if _MATH_PATTERNS.search(content):
        return True
    return False


# =========================================================================
# LLM Extraction
# =========================================================================

def _load_extraction_prompt() -> str:
    """Load the math extraction prompt from file."""
    prompt_path = Path(__file__).parent.parent / "prompts" / "math_extraction.txt"
    with open(prompt_path, "r", encoding="utf-8") as f:
        return f.read()


async def _extract_math_from_content(content: str) -> Optional[MathExtraction]:
    """
    Call the LLM to extract structured math data from content.

    Uses the 'math_extractor' task from the LLM registry (provider-agnostic).
    The LLMFactory dispatches to the configured provider (OpenAI, Gemini, etc.)
    and each adapter handles structured output natively.

    Returns None if extraction fails after all retries.
    """
    from inceptbench_new.llm.config import get_llm_config

    config = get_llm_config("math_extractor")
    llm = LLMFactory.create("math_extractor")
    system_prompt = _load_extraction_prompt()

    messages = [
        LLMMessage(role="system", content=system_prompt),
        LLMMessage(role="user", content=content),
    ]

    attempt_errors: List[AttemptError] = []

    for attempt in range(MAX_RETRIES + 1):
        try:
            extraction = await llm.generate_structured(
                messages=messages,
                response_schema=MathExtraction,
            )

            if extraction is not None:
                if attempt > 0:
                    FailureTracker.record_recovered(
                        component="math_verifier.extract",
                        message=f"Succeeded on attempt {attempt + 1}/{MAX_RETRIES + 1}",
                        context={"model": config.model},
                        attempt_errors=attempt_errors if attempt_errors else None,
                    )
                return extraction

            raise ValueError("No parsed output from LLM")

        except Exception as e:
            attempt_errors.append(
                AttemptError(attempt=attempt + 1, error_message=str(e))
            )
            if attempt < MAX_RETRIES:
                delay = BASE_DELAY * (2**attempt) + random.uniform(0, 1)
                logger.warning(
                    f"Math extraction error (attempt {attempt + 1}/{MAX_RETRIES + 1}): {e}. "
                    f"Retrying in {delay:.1f}s..."
                )
                await asyncio.sleep(delay)
            else:
                FailureTracker.record_exhausted(
                    component="math_verifier.extract",
                    error_message=str(e),
                    context={"model": config.model, "attempts": MAX_RETRIES + 1},
                    attempt_errors=attempt_errors,
                )
                logger.error(
                    f"Math extraction failed after {MAX_RETRIES + 1} attempts: {e}"
                )

    return None


# =========================================================================
# SymPy Safe Evaluation Helpers
# =========================================================================

# Namespace for sympify -- only safe SymPy functions
_SYMPY_NAMESPACE = {
    "Rational": Rational,
    "Float": Float,
    "sqrt": sqrt,
    "cbrt": cbrt,
    "pi": pi,
    "Abs": Abs,
    "S": S,
    "oo": oo,
    "Eq": Eq,
    "gcd": math_gcd,
    "lcm": lcm,
    "Symbol": Symbol,
}


def _safe_sympify(expr_str: str) -> Optional[sympy.Basic]:
    """
    Safely parse a string expression into a SymPy object.

    Returns None if parsing fails.
    """
    if not expr_str or not expr_str.strip():
        return None
    try:
        return sympify(expr_str, locals=_SYMPY_NAMESPACE)
    except Exception:
        try:
            # Fallback: try eval with restricted namespace
            return eval(expr_str, {"__builtins__": {}}, _SYMPY_NAMESPACE)
        except Exception:
            return None


def _expressions_equal(expr1, expr2) -> bool:
    """Check if two SymPy expressions are mathematically equal.

    Uses exact symbolic comparison first, then falls back to numeric
    tolerance (0.1 %) for cases involving floating-point / pi-approximation
    rounding.
    """
    # 1. Exact symbolic comparison
    try:
        diff = simplify(expr1 - expr2)
        if diff == 0:
            return True
    except Exception:
        pass
    try:
        if simplify(expr1) == simplify(expr2):
            return True
    except Exception:
        pass

    # 2. Numeric tolerance fallback (catches pi approximation rounding, etc.)
    try:
        v1 = float(expr1.evalf())
        v2 = float(expr2.evalf())
        denom = max(abs(v1), abs(v2), 1e-15)
        if abs(v1 - v2) / denom < NUMERIC_RELATIVE_TOLERANCE:
            return True
    except Exception:
        pass

    return False


def _sub_pi(expr: sympy.Basic, pi_val: Optional[sympy.Basic]) -> sympy.Basic:
    """Substitute symbolic pi with a numeric approximation in *expr*.

    Returns *expr* unchanged when *pi_val* is ``None`` or substitution
    fails.
    """
    if pi_val is None:
        return expr
    try:
        return expr.subs(pi, pi_val)
    except (AttributeError, TypeError):
        return expr


def _get_pi_val(extraction: MathExtraction) -> Optional[sympy.Basic]:
    """Return a SymPy number for the pi approximation, or ``None``."""
    if not extraction.pi_approximation:
        return None
    return _safe_sympify(extraction.pi_approximation)


# =========================================================================
# Generic Verifier
# =========================================================================

def verify_expression(
    expression: str, expected: str, pi_val=None
) -> Optional[MathVerificationResult]:
    """
    Generic verifier: evaluate an expression and compare to expected answer.

    Handles arithmetic, fractions, decimals, exponents, roots, order of
    operations, algebraic simplification, and any case where both sides can
    be sympified and compared.

    When *pi_val* is provided (a SymPy number), symbolic ``pi`` in the
    computed result is replaced with that approximation before comparison.
    """
    steps: List[str] = []

    computed_sym = _safe_sympify(expression)
    if computed_sym is None:
        return None
    steps.append(f"Parsed expression: {expression} -> {computed_sym}")

    # Evaluate to a number if possible
    try:
        computed_val = simplify(computed_sym)
    except Exception:
        computed_val = computed_sym

    # Apply pi approximation if specified
    computed_val = _sub_pi(computed_val, pi_val)
    try:
        computed_val = simplify(computed_val)
    except Exception:
        pass
    steps.append(f"Simplified: {computed_val}")

    expected_sym = _safe_sympify(expected)
    if expected_sym is None:
        return None
    expected_sym = _sub_pi(expected_sym, pi_val)
    steps.append(f"Expected answer: {expected} -> {expected_sym}")

    is_correct = _expressions_equal(computed_val, expected_sym)
    steps.append(f"Match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=str(computed_val),
        expected_answer=str(expected_sym),
        verification_steps=steps,
        details=(
            f"Expression '{expression}' evaluates to {computed_val}. "
            f"Expected answer '{expected}' evaluates to {expected_sym}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


def check_expression_equivalence(expr1: str, expr2: str) -> Optional[bool]:
    """Check if two expression strings are mathematically equivalent."""
    sym1 = _safe_sympify(expr1)
    sym2 = _safe_sympify(expr2)
    if sym1 is None or sym2 is None:
        return None
    return _expressions_equal(sym1, sym2)


# =========================================================================
# Category 1: Arithmetic & Algebra
# =========================================================================

def _parse_expected_answers(
    expected: Optional[str],
    expected_list: Optional[List[str]],
    pi_val: Optional[sympy.Basic] = None,
) -> Optional[List[sympy.Basic]]:
    """Parse expected answers from either a list or a single string.

    Tries, in order:
    1. ``expected_list`` (the new ``expected_answers`` field)
    2. ``expected`` split on " or " / ", " (handles "3 or 1/2", "3, 1/2")
    3. ``expected`` as a single value
    Returns None if nothing can be parsed.
    """
    raw_parts: List[str] = []
    if expected_list:
        raw_parts = [s.strip() for s in expected_list if s and s.strip()]
    elif expected:
        # Try splitting on common multi-answer separators
        if " or " in expected:
            raw_parts = [s.strip() for s in expected.split(" or ")]
        elif ", " in expected and not expected.startswith("Rational"):
            raw_parts = [s.strip() for s in expected.split(", ")]
        else:
            raw_parts = [expected.strip()]

    parsed: List[sympy.Basic] = []
    for part in raw_parts:
        # Strip leading "x = " or "y = " prefixes
        cleaned = re.sub(r"^[a-zA-Z]\s*=\s*", "", part)
        sym = _safe_sympify(cleaned)
        if sym is not None:
            sym = _sub_pi(sym, pi_val)
            parsed.append(sym)

    return parsed if parsed else None


def verify_equation_solution(
    equation_str: str, variable_str: str, expected: str,
    pi_val: Optional[sympy.Basic] = None,
    expected_answers: Optional[List[str]] = None,
) -> Optional[MathVerificationResult]:
    """
    Solve an equation and verify the expected solution(s).

    Handles linear, quadratic, and rational equations.  For multi-solution
    equations (quadratics etc.), pass ``expected_answers`` as a list of
    SymPy-parseable strings.  When *pi_val* is provided, substitutes pi in
    the equation before solving.
    """
    steps: List[str] = []

    try:
        var = Symbol(variable_str)
    except Exception:
        return None

    eq_sym = _safe_sympify(equation_str)
    if eq_sym is None:
        return None

    eq_sym = _sub_pi(eq_sym, pi_val)
    steps.append(f"Parsed equation: {eq_sym}")

    try:
        solutions = solve(eq_sym, var)
    except Exception:
        return None
    steps.append(f"Solutions: {solutions}")

    expected_syms = _parse_expected_answers(expected, expected_answers, pi_val)
    if not expected_syms:
        return None
    steps.append(f"Expected: {expected_syms}")

    # Check correctness: every expected value must match a computed solution
    # AND every computed solution must match an expected value (set equality).
    if len(expected_syms) == 1:
        # Single expected answer: at least one solution must match
        is_correct = any(
            _expressions_equal(sol, expected_syms[0]) for sol in solutions
        )
    else:
        # Multi-solution: sets must match
        all_expected_found = all(
            any(_expressions_equal(sol, exp) for sol in solutions)
            for exp in expected_syms
        )
        all_computed_found = all(
            any(_expressions_equal(sol, exp) for exp in expected_syms)
            for sol in solutions
        )
        is_correct = all_expected_found and all_computed_found

    computed_str = ", ".join(str(s) for s in solutions)
    expected_str = ", ".join(str(s) for s in expected_syms)
    steps.append(f"Match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=computed_str,
        expected_answer=expected_str,
        verification_steps=steps,
        details=(
            f"Equation {eq_sym}: solutions are [{computed_str}]. "
            f"Expected [{expected_str}]. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


def verify_system_of_equations(
    equations: List[str], variables: List[str], expected: dict
) -> Optional[MathVerificationResult]:
    """Solve a system of equations and verify expected solutions."""
    steps: List[str] = []

    try:
        var_syms = [Symbol(v) for v in variables]
        eq_syms = [_safe_sympify(eq) for eq in equations]
        if any(e is None for e in eq_syms):
            return None
    except Exception:
        return None
    steps.append(f"System: {eq_syms}, variables: {var_syms}")

    try:
        solution = solve(eq_syms, var_syms, dict=True)
    except Exception:
        return None

    if not solution:
        steps.append("No solution found")
        return MathVerificationResult(
            is_correct=False,
            computed_answer="No solution",
            expected_answer=str(expected),
            verification_steps=steps,
            details="System has no solution.",
        )

    sol = solution[0]
    steps.append(f"Solution: {sol}")

    is_correct = True
    for var_name, exp_val in expected.items():
        var_sym = Symbol(var_name)
        exp_sym = _safe_sympify(str(exp_val))
        if var_sym in sol and exp_sym is not None:
            if not _expressions_equal(sol[var_sym], exp_sym):
                is_correct = False
                break
        else:
            is_correct = False
            break

    computed_str = ", ".join(f"{k}={v}" for k, v in sol.items())
    steps.append(f"Match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=computed_str,
        expected_answer=str(expected),
        verification_steps=steps,
        details=(
            f"System solution: {computed_str}. "
            f"Expected: {expected}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


# =========================================================================
# Category 2: Geometry
# =========================================================================

_GEOMETRY_FORMULAS = {
    # 2D
    "rectangle_area": lambda d: d["length"] * d["width"],
    "rectangle_perimeter": lambda d: 2 * (d["length"] + d["width"]),
    "triangle_area": lambda d: Rational(1, 2) * d["base"] * d["height"],
    "circle_area": lambda d: pi * d["radius"] ** 2,
    "circle_circumference": lambda d: 2 * pi * d["radius"],
    "trapezoid_area": lambda d: Rational(1, 2) * (d["base1"] + d["base2"]) * d["height"],
    "parallelogram_area": lambda d: d["base"] * d["height"],
    # 3D
    "rectangular_prism_volume": lambda d: d["length"] * d["width"] * d["height"],
    "rectangular_prism_surface_area": lambda d: 2 * (
        d["length"] * d["width"]
        + d["length"] * d["height"]
        + d["width"] * d["height"]
    ),
    "cylinder_volume": lambda d: pi * d["radius"] ** 2 * d["height"],
    "cylinder_surface_area": lambda d: (
        2 * pi * d["radius"] * d["height"] + 2 * pi * d["radius"] ** 2
    ),
    "cone_volume": lambda d: Rational(1, 3) * pi * d["radius"] ** 2 * d["height"],
    "sphere_volume": lambda d: Rational(4, 3) * pi * d["radius"] ** 3,
    "sphere_surface_area": lambda d: 4 * pi * d["radius"] ** 2,
    "pyramid_volume": lambda d: Rational(1, 3) * d["base_area"] * d["height"],
}


def _parse_json_field(value: Optional[str]) -> Optional[dict]:
    """Parse a JSON string field to a dict. Returns None on failure."""
    if value is None:
        return None
    if isinstance(value, dict):
        return value
    try:
        parsed = json.loads(value)
        return parsed if isinstance(parsed, dict) else None
    except (json.JSONDecodeError, TypeError):
        return None


def verify_geometry(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Verify geometry problems using formula-based computation.

    Handles area, perimeter, volume, surface area, Pythagorean theorem,
    coordinate distance, and angle relationships.

    When the problem specifies a pi approximation (e.g. π ≈ 3.14), the
    symbolic ``pi`` in computed results is replaced with that approximation
    before comparison so that answers like 20.56 match 4×3.14 + 8.
    """
    shape_data = _parse_json_field(extraction.shape_data)
    operation = extraction.operation or ""
    expected = extraction.expected_answer
    pi_val = _get_pi_val(extraction)

    if not shape_data or not expected:
        # Fall back to generic expression evaluation
        if extraction.expression and expected:
            return verify_expression(extraction.expression, expected, pi_val=pi_val)
        return None

    steps: List[str] = []
    shape_type = shape_data.get("type", "unknown")
    steps.append(f"Shape: {shape_type}, operation: {operation}")
    if pi_val is not None:
        steps.append(f"Using pi approximation: {pi_val}")

    # Build formula key (surface_area must be checked before area)
    formula_key = None
    if "surface_area" in operation:
        formula_key = f"{shape_type}_surface_area"
    elif "area" in operation:
        formula_key = f"{shape_type}_area"
    elif "perimeter" in operation or "circumference" in operation:
        if shape_type == "circle":
            formula_key = "circle_circumference"
        else:
            formula_key = f"{shape_type}_perimeter"
    elif "volume" in operation:
        formula_key = f"{shape_type}_volume"

    if formula_key and formula_key in _GEOMETRY_FORMULAS:
        try:
            # Convert dimensions to SymPy Rationals for exact arithmetic
            dims = {}
            for k, v in shape_data.items():
                if k != "type":
                    dims[k] = sympify(v)

            computed = _GEOMETRY_FORMULAS[formula_key](dims)
            computed = simplify(computed)
            steps.append(f"Formula ({formula_key}): computed = {computed}")

            # Apply pi approximation before comparison
            computed = _sub_pi(computed, pi_val)
            try:
                computed = simplify(computed)
            except Exception:
                pass

            expected_sym = _safe_sympify(expected)
            if expected_sym is None:
                return None
            expected_sym = _sub_pi(expected_sym, pi_val)

            is_correct = _expressions_equal(computed, expected_sym)
            steps.append(f"Expected: {expected_sym}, match: {is_correct}")

            return MathVerificationResult(
                is_correct=is_correct,
                computed_answer=str(computed),
                expected_answer=str(expected_sym),
                verification_steps=steps,
                details=(
                    f"Geometry ({shape_type} {operation}): computed {computed}, "
                    f"expected {expected_sym}. "
                    f"{'CORRECT' if is_correct else 'INCORRECT'}."
                ),
            )
        except (KeyError, TypeError) as e:
            steps.append(f"Formula computation failed: {e}")

    # Pythagorean theorem
    if "pythagorean" in operation or "hypotenuse" in operation or "distance" in operation:
        result = _verify_pythagorean(shape_data, expected, steps)
        if result is not None:
            return result

    # Angle relationships
    if "angle" in operation:
        result = _verify_angles(shape_data, expected, steps)
        if result is not None:
            return result

    # Fallback to expression evaluation
    if extraction.expression and expected:
        return verify_expression(extraction.expression, expected, pi_val=pi_val)

    return None


def _verify_pythagorean(
    shape_data: dict, expected: str, steps: List[str]
) -> Optional[MathVerificationResult]:
    """Verify Pythagorean theorem problems."""
    a = shape_data.get("a") or shape_data.get("leg1")
    b = shape_data.get("b") or shape_data.get("leg2")
    c = shape_data.get("c") or shape_data.get("hypotenuse")

    if a is not None and b is not None and c is None:
        # Find hypotenuse
        a_sym, b_sym = sympify(a), sympify(b)
        computed = sqrt(a_sym**2 + b_sym**2)
        computed = simplify(computed)
        steps.append(f"Pythagorean: sqrt({a}² + {b}²) = {computed}")
    elif a is not None and c is not None and b is None:
        # Find missing leg
        a_sym, c_sym = sympify(a), sympify(c)
        computed = sqrt(c_sym**2 - a_sym**2)
        computed = simplify(computed)
        steps.append(f"Pythagorean: sqrt({c}² - {a}²) = {computed}")
    elif b is not None and c is not None and a is None:
        b_sym, c_sym = sympify(b), sympify(c)
        computed = sqrt(c_sym**2 - b_sym**2)
        computed = simplify(computed)
        steps.append(f"Pythagorean: sqrt({c}² - {b}²) = {computed}")
    else:
        return None

    expected_sym = _safe_sympify(expected)
    if expected_sym is None:
        return None

    is_correct = _expressions_equal(computed, expected_sym)
    steps.append(f"Expected: {expected_sym}, match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=str(computed),
        expected_answer=str(expected_sym),
        verification_steps=steps,
        details=(
            f"Pythagorean theorem: computed {computed}, expected {expected_sym}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


def _verify_angles(
    shape_data: dict, expected: str, steps: List[str]
) -> Optional[MathVerificationResult]:
    """Verify angle relationship problems."""
    known_angles = shape_data.get("known_angles", [])
    relationship = shape_data.get("relationship", "")
    n_sides = shape_data.get("n_sides")

    if relationship == "supplementary":
        total = 180
    elif relationship == "complementary":
        total = 90
    elif relationship == "triangle" or n_sides == 3:
        total = 180
    elif n_sides and n_sides >= 3:
        total = (n_sides - 2) * 180
    else:
        total = None

    if total is not None and known_angles:
        known_sum = sum(sympify(a) for a in known_angles)
        computed = sympify(total) - known_sum
        steps.append(f"Angle sum = {total}, known = {known_sum}, missing = {computed}")

        expected_sym = _safe_sympify(expected)
        if expected_sym is None:
            return None

        is_correct = _expressions_equal(computed, expected_sym)
        steps.append(f"Expected: {expected_sym}, match: {is_correct}")

        return MathVerificationResult(
            is_correct=is_correct,
            computed_answer=str(computed),
            expected_answer=str(expected_sym),
            verification_steps=steps,
            details=(
                f"Angle relationship ({relationship or 'polygon'}): "
                f"missing angle = {computed}, expected {expected_sym}. "
                f"{'CORRECT' if is_correct else 'INCORRECT'}."
            ),
        )

    return None


# =========================================================================
# Category 3: Statistics & Data
# =========================================================================

def verify_statistics(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Verify statistics problems using Python's statistics module.

    Handles mean, median, mode, range, MAD, IQR.
    """
    data = extraction.data_set
    operation = extraction.operation or ""
    expected = extraction.expected_answer

    if not data or not expected:
        return None

    steps: List[str] = []
    steps.append(f"Data set: {data}")
    steps.append(f"Operation: {operation}")

    computed = None
    try:
        if "mean" in operation or "average" in operation:
            computed = pystats.mean(data)
            steps.append(f"Mean = sum({data}) / {len(data)} = {computed}")
        elif "median" in operation:
            computed = pystats.median(data)
            steps.append(f"Median = {computed}")
        elif "mode" in operation:
            try:
                computed = pystats.mode(data)
            except pystats.StatisticsError:
                computed = "no unique mode"
            steps.append(f"Mode = {computed}")
        elif "range" in operation:
            computed = max(data) - min(data)
            steps.append(f"Range = {max(data)} - {min(data)} = {computed}")
        elif "mad" in operation.lower():
            mean_val = pystats.mean(data)
            deviations = [abs(x - mean_val) for x in data]
            computed = pystats.mean(deviations)
            steps.append(f"MAD: mean={mean_val}, deviations={deviations}, MAD={computed}")
        elif "iqr" in operation.lower():
            sorted_data = sorted(data)
            n = len(sorted_data)
            mid = n // 2
            lower = sorted_data[:mid]
            upper = sorted_data[mid + (n % 2):]
            q1 = pystats.median(lower) if lower else 0
            q3 = pystats.median(upper) if upper else 0
            computed = q3 - q1
            steps.append(f"IQR: Q1={q1}, Q3={q3}, IQR={computed}")
        elif "probability" in operation or "find_probability" in operation:
            # Fallback to expression evaluation for probability
            if extraction.expression:
                return verify_expression(extraction.expression, expected)
            return None
        else:
            return None

    except Exception as e:
        steps.append(f"Computation error: {e}")
        return None

    if computed is None:
        return None

    # Compare
    expected_sym = _safe_sympify(str(expected))
    computed_sym = _safe_sympify(str(computed))

    if expected_sym is None or computed_sym is None:
        is_correct = str(computed) == str(expected)
    else:
        is_correct = _expressions_equal(computed_sym, expected_sym)

    steps.append(f"Computed: {computed}, Expected: {expected}, Match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=str(computed),
        expected_answer=str(expected),
        verification_steps=steps,
        details=(
            f"Statistics ({operation}): computed {computed}, expected {expected}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


# =========================================================================
# Category 4: Number Theory
# =========================================================================

def verify_number_theory(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Verify number theory problems using sympy.ntheory.

    Handles prime testing, factorization, GCF, LCM, divisibility, factor
    enumeration.
    """
    operation = extraction.operation or ""
    expected = extraction.expected_answer
    expression = extraction.expression

    if not expected:
        return None

    steps: List[str] = []

    try:
        if "is_prime" in operation or "prime" in operation.lower():
            if expression:
                n = int(sympify(expression))
                computed = isprime(n)
                steps.append(f"isprime({n}) = {computed}")

                expected_bool = str(expected).lower() in ("true", "1", "yes", "prime")
                is_correct = computed == expected_bool

                return MathVerificationResult(
                    is_correct=is_correct,
                    computed_answer=str(computed),
                    expected_answer=str(expected),
                    verification_steps=steps,
                    details=(
                        f"Prime test: {n} is {'prime' if computed else 'not prime'}. "
                        f"{'CORRECT' if is_correct else 'INCORRECT'}."
                    ),
                )

        if "factor" in operation.lower() and "gcd" not in operation.lower() and "gcf" not in operation.lower():
            if expression:
                n = int(sympify(expression))
                factors = sorted(divisors(n))
                computed_str = str(factors)
                steps.append(f"divisors({n}) = {factors}")

                expected_sym = _safe_sympify(expected)
                if expected_sym is not None:
                    is_correct = _expressions_equal(sympify(len(factors)), expected_sym)
                else:
                    is_correct = str(expected) in computed_str

                return MathVerificationResult(
                    is_correct=is_correct,
                    computed_answer=computed_str,
                    expected_answer=str(expected),
                    verification_steps=steps,
                    details=f"Factors of {n}: {factors}.",
                )

        # GCF / GCD
        if "gcd" in operation.lower() or "gcf" in operation.lower():
            if expression:
                result = _safe_sympify(expression)
                if result is not None:
                    computed = int(result)
                else:
                    return None
            else:
                return None
            steps.append(f"GCD/GCF computed: {computed}")
            return _compare_and_return(computed, expected, steps, f"GCF/GCD = {computed}")

        # LCM
        if "lcm" in operation.lower():
            if expression:
                result = _safe_sympify(expression)
                if result is not None:
                    computed = int(result)
                else:
                    return None
            else:
                return None
            steps.append(f"LCM computed: {computed}")
            return _compare_and_return(computed, expected, steps, f"LCM = {computed}")

    except Exception as e:
        steps.append(f"Number theory error: {e}")

    # Fallback to generic expression evaluation
    if expression and expected:
        return verify_expression(expression, expected)

    return None


# =========================================================================
# Category 5: Measurement & Unit Conversion
# =========================================================================

_CONVERSION_FACTORS = {
    # Length
    ("feet", "inches"): Rational(12),
    ("inches", "feet"): Rational(1, 12),
    ("yards", "feet"): Rational(3),
    ("feet", "yards"): Rational(1, 3),
    ("miles", "feet"): Rational(5280),
    ("feet", "miles"): Rational(1, 5280),
    ("yards", "inches"): Rational(36),
    ("inches", "yards"): Rational(1, 36),
    ("miles", "yards"): Rational(1760),
    ("yards", "miles"): Rational(1, 1760),
    # Metric length
    ("meters", "centimeters"): Rational(100),
    ("centimeters", "meters"): Rational(1, 100),
    ("kilometers", "meters"): Rational(1000),
    ("meters", "kilometers"): Rational(1, 1000),
    ("meters", "millimeters"): Rational(1000),
    ("millimeters", "meters"): Rational(1, 1000),
    ("centimeters", "millimeters"): Rational(10),
    ("millimeters", "centimeters"): Rational(1, 10),
    ("kilometers", "centimeters"): Rational(100000),
    ("centimeters", "kilometers"): Rational(1, 100000),
    # Weight (US customary)
    ("pounds", "ounces"): Rational(16),
    ("ounces", "pounds"): Rational(1, 16),
    ("tons", "pounds"): Rational(2000),
    ("pounds", "tons"): Rational(1, 2000),
    # Weight (metric)
    ("kilograms", "grams"): Rational(1000),
    ("grams", "kilograms"): Rational(1, 1000),
    ("kilograms", "milligrams"): Rational(1000000),
    ("milligrams", "kilograms"): Rational(1, 1000000),
    ("grams", "milligrams"): Rational(1000),
    ("milligrams", "grams"): Rational(1, 1000),
    # Volume (US customary)
    ("gallons", "quarts"): Rational(4),
    ("quarts", "gallons"): Rational(1, 4),
    ("quarts", "pints"): Rational(2),
    ("pints", "quarts"): Rational(1, 2),
    ("pints", "cups"): Rational(2),
    ("cups", "pints"): Rational(1, 2),
    ("gallons", "pints"): Rational(8),
    ("pints", "gallons"): Rational(1, 8),
    ("gallons", "cups"): Rational(16),
    ("cups", "gallons"): Rational(1, 16),
    # Volume (metric)
    ("liters", "milliliters"): Rational(1000),
    ("milliliters", "liters"): Rational(1, 1000),
    # Time
    ("hours", "minutes"): Rational(60),
    ("minutes", "hours"): Rational(1, 60),
    ("minutes", "seconds"): Rational(60),
    ("seconds", "minutes"): Rational(1, 60),
    ("hours", "seconds"): Rational(3600),
    ("seconds", "hours"): Rational(1, 3600),
    ("days", "hours"): Rational(24),
    ("hours", "days"): Rational(1, 24),
    ("weeks", "days"): Rational(7),
    ("days", "weeks"): Rational(1, 7),
    # Money
    ("dollars", "cents"): Rational(100),
    ("cents", "dollars"): Rational(1, 100),
}

# Aliases for unit name normalization
_UNIT_ALIASES = {
    "ft": "feet",
    "foot": "feet",
    "in": "inches",
    "inch": "inches",
    "yd": "yards",
    "yard": "yards",
    "mi": "miles",
    "mile": "miles",
    "m": "meters",
    "meter": "meters",
    "cm": "centimeters",
    "centimeter": "centimeters",
    "km": "kilometers",
    "kilometer": "kilometers",
    "mm": "millimeters",
    "millimeter": "millimeters",
    "lb": "pounds",
    "pound": "pounds",
    "oz": "ounces",
    "ounce": "ounces",
    "kg": "kilograms",
    "kilogram": "kilograms",
    "g": "grams",
    "gram": "grams",
    "mg": "milligrams",
    "milligram": "milligrams",
    "gal": "gallons",
    "gallon": "gallons",
    "qt": "quarts",
    "quart": "quarts",
    "pt": "pints",
    "pint": "pints",
    "cup": "cups",
    "L": "liters",
    "l": "liters",
    "liter": "liters",
    "mL": "milliliters",
    "ml": "milliliters",
    "milliliter": "milliliters",
    "hr": "hours",
    "hour": "hours",
    "min": "minutes",
    "minute": "minutes",
    "sec": "seconds",
    "second": "seconds",
    "day": "days",
    "week": "weeks",
    "dollar": "dollars",
    "cent": "cents",
}


def _normalize_unit(unit: str) -> str:
    """Normalize a unit name to its canonical plural form."""
    u = unit.strip().lower().rstrip(".")
    return _UNIT_ALIASES.get(u, u)


def verify_measurement(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Verify unit conversion problems using a lookup table and exact Rational
    arithmetic.
    """
    units_data = _parse_json_field(extraction.units)
    expected = extraction.expected_answer

    if not units_data or not expected:
        # Fallback to expression evaluation
        if extraction.expression and expected:
            return verify_expression(extraction.expression, expected)
        return None

    steps: List[str] = []

    value = units_data.get("value")
    from_unit = _normalize_unit(str(units_data.get("from_unit", "")))
    to_unit = _normalize_unit(str(units_data.get("to_unit", "")))

    if value is None:
        return None

    steps.append(f"Convert {value} {from_unit} to {to_unit}")

    # Temperature special case
    if from_unit in ("celsius", "c", "°c") and to_unit in ("fahrenheit", "f", "°f"):
        computed = Rational(9, 5) * sympify(value) + 32
        steps.append(f"F = (9/5) × {value} + 32 = {computed}")
    elif from_unit in ("fahrenheit", "f", "°f") and to_unit in ("celsius", "c", "°c"):
        computed = Rational(5, 9) * (sympify(value) - 32)
        steps.append(f"C = (5/9) × ({value} - 32) = {computed}")
    else:
        key = (from_unit, to_unit)
        factor = _CONVERSION_FACTORS.get(key)
        if factor is None:
            steps.append(f"No conversion factor for {from_unit} -> {to_unit}")
            # Fallback to expression
            if extraction.expression and expected:
                return verify_expression(extraction.expression, expected)
            return None

        computed = sympify(value) * factor
        steps.append(f"Factor: 1 {from_unit} = {factor} {to_unit}, computed = {computed}")

    computed = simplify(computed)
    expected_sym = _safe_sympify(expected)
    if expected_sym is None:
        return None

    is_correct = _expressions_equal(computed, expected_sym)
    steps.append(f"Expected: {expected_sym}, match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=str(computed),
        expected_answer=str(expected_sym),
        verification_steps=steps,
        details=(
            f"Unit conversion: {value} {from_unit} = {computed} {to_unit}. "
            f"Expected {expected_sym}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


# =========================================================================
# MCQ Option Analysis
# =========================================================================

def _analyze_all_options(
    extraction: MathExtraction,
    computed_answer,
) -> Optional[List[OptionAnalysis]]:
    """
    Analyze all MCQ options against the computed correct answer.

    Applies the same pi-approximation substitution as the main verifier so
    that decimal options like "20.56" match the computed value after pi
    substitution.

    Flags:
    - Which option is correct
    - If any distractor is accidentally correct
    - If the labeled answer differs from the computed answer
    """
    if not extraction.all_options:
        return None

    pi_val = _get_pi_val(extraction)

    analyses = []
    computed_sym = _safe_sympify(str(computed_answer))
    computed_sym = _sub_pi(computed_sym, pi_val)

    for opt_str in extraction.all_options:
        opt_sym = _safe_sympify(opt_str)
        opt_sym = _sub_pi(opt_sym, pi_val)
        if opt_sym is not None and computed_sym is not None:
            is_correct = _expressions_equal(opt_sym, computed_sym)
        else:
            is_correct = str(opt_str).strip() == str(computed_answer).strip()

        note = ""
        if is_correct:
            note = "Matches computed answer"
        analyses.append(
            OptionAnalysis(
                option=opt_str,
                is_correct=is_correct,
                computed_note=note,
            )
        )

    return analyses


# =========================================================================
# Shared helpers
# =========================================================================

def _compare_and_return(
    computed, expected_str: str, steps: List[str], context: str
) -> MathVerificationResult:
    """Compare a computed value to an expected string and return a result."""
    expected_sym = _safe_sympify(str(expected_str))
    computed_sym = _safe_sympify(str(computed))

    if expected_sym is not None and computed_sym is not None:
        is_correct = _expressions_equal(computed_sym, expected_sym)
    else:
        is_correct = str(computed) == str(expected_str)

    steps.append(f"Computed: {computed}, Expected: {expected_str}, Match: {is_correct}")

    return MathVerificationResult(
        is_correct=is_correct,
        computed_answer=str(computed),
        expected_answer=str(expected_str),
        verification_steps=steps,
        details=(
            f"{context}. Expected {expected_str}. "
            f"{'CORRECT' if is_correct else 'INCORRECT'}."
        ),
    )


# Common pi approximations used in K-12 education, tried as fallback when
# pi_approximation is not explicitly extracted from the question.
_COMMON_PI_APPROXIMATIONS = [
    Rational(314, 100),   # π ≈ 3.14
    Rational(22, 7),      # π = 22/7
    Rational(31416, 10000),  # π ≈ 3.1416
]


def _expression_uses_pi(expr_str: Optional[str]) -> bool:
    """Check if an expression string likely involves pi."""
    if not expr_str:
        return False
    return "pi" in expr_str.lower()


def _try_pi_fallback(
    extraction: MathExtraction,
    result: MathVerificationResult,
) -> MathVerificationResult:
    """
    When verification returns INCORRECT and pi is involved, re-try with
    common K-12 pi approximations (3.14, 22/7, 3.1416).

    This is a safety net for when the LLM extractor fails to set
    pi_approximation even though the question specifies one.
    """
    if result.is_correct is not False:
        return result

    # Only try fallback if pi is involved in the expressions
    has_pi = (
        _expression_uses_pi(extraction.expression)
        or _expression_uses_pi(extraction.expected_answer)
        or (extraction.shape_data and "circle" in (extraction.shape_data or ""))
        or (extraction.shape_data and "sphere" in (extraction.shape_data or ""))
        or (extraction.shape_data and "cylinder" in (extraction.shape_data or ""))
        or (extraction.shape_data and "cone" in (extraction.shape_data or ""))
        or "pi" in result.computed_answer.lower()
    )
    if not has_pi:
        return result

    for pi_approx in _COMMON_PI_APPROXIMATIONS:
        # Create a modified extraction with the pi approximation
        modified = extraction.model_copy(
            update={"pi_approximation": str(pi_approx)}
        )
        try:
            retry_result = _route_verification_inner(modified)
            if retry_result is not None and retry_result.is_correct is True:
                retry_result.verification_steps.append(
                    f"Pi fallback: succeeded with pi ≈ {pi_approx}"
                )
                logger.debug(
                    f"Pi fallback: answer verified correct with pi ≈ {pi_approx}"
                )
                return retry_result
        except Exception:
            continue

    return result


# =========================================================================
# Routing: dispatch to the right verifier
# =========================================================================

def _build_system_expected_dict(extraction: MathExtraction) -> Optional[dict]:
    """Build an {var_name: expected_value} dict from extraction fields."""
    if extraction.expected_answers and extraction.variables:
        if len(extraction.expected_answers) == len(extraction.variables):
            return dict(zip(extraction.variables, extraction.expected_answers))
    return None


def _route_verification_inner(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Core routing logic — dispatches to the appropriate domain verifier.

    Called directly for normal verification and recursively by the pi-
    approximation fallback.
    """
    domain = extraction.domain or "other"

    # Systems of equations → system solver (must check BEFORE single equation)
    if extraction.equations and extraction.variables:
        expected_dict = _build_system_expected_dict(extraction) or {}
        return verify_system_of_equations(
            extraction.equations, extraction.variables, expected_dict,
        )

    # Single equation → algebra verifier (now with multi-solution support)
    if extraction.equation and extraction.variable:
        pi_val = _get_pi_val(extraction)
        return verify_equation_solution(
            extraction.equation, extraction.variable,
            extraction.expected_answer or "",
            pi_val=pi_val,
            expected_answers=extraction.expected_answers,
        )

    # Geometry with shape data
    parsed_shape = _parse_json_field(extraction.shape_data)
    if domain == "geometry" or (parsed_shape and parsed_shape.get("type")):
        result = verify_geometry(extraction)
        if result is not None:
            return result

    # Statistics with data set
    if domain == "statistics" and extraction.data_set:
        result = verify_statistics(extraction)
        if result is not None:
            return result

    # Number theory
    if domain == "number_theory":
        result = verify_number_theory(extraction)
        if result is not None:
            return result

    # Measurement / unit conversion
    if domain == "measurement" and _parse_json_field(extraction.units):
        result = verify_measurement(extraction)
        if result is not None:
            return result

    # Generic expression evaluation (catches arithmetic, fractions, decimals,
    # exponents, roots, order of operations, percentages, ratios, etc.)
    if extraction.expression and extraction.expected_answer:
        pi_val = _get_pi_val(extraction)
        return verify_expression(
            extraction.expression, extraction.expected_answer, pi_val=pi_val
        )

    return None


def _route_verification(extraction: MathExtraction) -> Optional[MathVerificationResult]:
    """
    Route a MathExtraction to the appropriate domain-specific verifier.

    Falls back to generic expression evaluation when no specialized verifier
    matches.  When verification returns INCORRECT and the expression involves
    pi, retries with common K-12 pi approximations as a safety net.
    """
    result = _route_verification_inner(extraction)

    # Pi-approximation fallback: when the LLM extractor didn't set
    # pi_approximation but the question uses an approximate pi value.
    if result is not None and not extraction.pi_approximation:
        result = _try_pi_fallback(extraction, result)

    return result


# =========================================================================
# Prompt Context Formatting
# =========================================================================

def _format_verification_for_prompt(result: MathVerificationResult) -> str:
    """
    Format a MathVerificationResult into text for inclusion in the evaluator
    system prompt.
    """
    lines = []
    lines.append("")
    lines.append("## MATHEMATICAL VERIFICATION DATA (Pre-computed — Deterministic Ground Truth)")
    lines.append("")
    lines.append(
        "The following was computed using symbolic mathematics (SymPy). "
        "When the result is CORRECT, it is mathematically provable ground truth. "
        "When the result is INCORRECT, it is usually reliable but depends on "
        "correct extraction of the mathematical content from the question text."
    )
    lines.append("")

    if result.is_correct is True:
        lines.append(f"**Verification result: CORRECT**")
    elif result.is_correct is False:
        lines.append(f"**Verification result: INCORRECT**")
    else:
        lines.append(f"**Verification result: UNABLE TO VERIFY**")

    lines.append(f"- Computed correct answer: **{result.computed_answer}**")
    lines.append(f"- Expected (labeled) answer: **{result.expected_answer}**")
    lines.append("")

    if result.verification_steps:
        lines.append("**Verification steps:**")
        for step in result.verification_steps:
            lines.append(f"  - {step}")
        lines.append("")

    if result.all_options_analysis:
        lines.append("**MCQ Options Analysis:**")
        correct_count = 0
        for opt in result.all_options_analysis:
            status = "CORRECT" if opt.is_correct else "incorrect"
            note = f" ({opt.computed_note})" if opt.computed_note else ""
            lines.append(f"  - {opt.option}: {status}{note}")
            if opt.is_correct:
                correct_count += 1
        if correct_count == 0:
            lines.append("  - WARNING: No option matches the computed answer")
        elif correct_count > 1:
            lines.append(
                f"  - WARNING: {correct_count} options evaluate to the same correct answer"
            )
        lines.append("")

    lines.append(f"**Details:** {result.details}")
    lines.append("")
    lines.append(
        "Rules:\n"
        "- If verification says CORRECT: the labeled answer IS mathematically correct — "
        "you MUST set factual_accuracy = 1.0 for the mathematical claim. "
        "Do NOT penalise factual_accuracy for issues in the explanation text "
        "(e.g. mismatched example numbers or wording inconsistencies); "
        "those belong under clarity_precision or distractor_quality.\n"
        "- If verification says INCORRECT: you SHOULD set factual_accuracy = 0.0 and cite "
        "the computed answer. However, if your own independent mathematical verification "
        "clearly confirms the labeled answer IS correct and you believe the automated "
        "extraction may have misrepresented the problem (e.g. wrong equation format, "
        "missing variables), you may override with factual_accuracy = 1.0 and explain "
        "the discrepancy in your reasoning.\n"
        "- If verification says UNABLE TO VERIFY: fall back to your own mathematical reasoning.\n"
        "- Speak about the analysis as if you performed it yourself."
    )
    lines.append("")

    return "\n".join(lines)


# =========================================================================
# Main Entry Point
# =========================================================================

async def extract_and_verify_math(content: str) -> str:
    """
    Extract mathematical content from educational text and verify it
    deterministically using SymPy.

    This is the main entry point called by evaluators via
    _get_programmatic_context().

    Returns a formatted string for inclusion in the system prompt, or an
    empty string if:
    - Content has no verifiable math
    - Extraction fails
    - SymPy cannot verify the extracted data
    - DISABLE_MATH_VERIFIER env var is set (for A/B comparison testing)

    The empty-string fallback means the evaluator LLM continues as before
    with zero degradation.
    """
    if os.environ.get("DISABLE_MATH_VERIFIER"):
        return ""

    # Gate: lightweight regex check
    if not _has_math_content(content):
        logger.debug("Math verifier: no math content detected, skipping")
        return ""

    # Step 1: LLM extraction
    extraction = await _extract_math_from_content(content)
    if extraction is None:
        logger.warning("Math verifier: extraction returned None, skipping")
        return ""

    if not extraction.has_math:
        logger.debug("Math verifier: LLM says no math content, skipping")
        return ""

    # Step 2: SymPy verification
    try:
        result = _route_verification(extraction)
    except Exception as e:
        logger.warning(f"Math verifier: SymPy verification error: {e}")
        return ""

    if result is None:
        logger.debug("Math verifier: unable to verify, skipping")
        return ""

    # Step 3: Analyze all MCQ options if available
    if extraction.all_options:
        try:
            result.all_options_analysis = _analyze_all_options(
                extraction, result.computed_answer
            )
        except Exception as e:
            logger.debug(f"Math verifier: options analysis error: {e}")

    # Step 4: Format for prompt
    context = _format_verification_for_prompt(result)
    logger.info(
        f"Math verifier: verified ({extraction.domain}/{extraction.operation}), "
        f"correct={result.is_correct}, computed={result.computed_answer}"
    )
    return context
