"""
Tests for RAVS v2 — Plan Compiler, Executors, Verifiers, Shadow Runner.

Coverage:
  V2-01  COMPILER BASICS          query compiles to plan with nodes
  V2-02  COMPUTATION DETECTED     math queries produce computation nodes
  V2-03  CODE_INSPECTION DETECTED function/class queries produce inspection nodes
  V2-04  TEST_EXECUTION DETECTED  "run tests" produces test nodes
  V2-05  RETRIEVAL DETECTED       "according to docs" produces retrieval nodes
  V2-06  MODEL_BOUND ALWAYS       every plan has at least one model_bound node
  V2-07  PLAN SERIALIZABLE        plan.to_json() produces valid JSON
  V2-08  SYMPY EXECUTOR           symbolic math evaluates correctly
  V2-09  PYTHON EXECUTOR          safe arithmetic evaluates correctly
  V2-10  PYTHON REJECTS UNSAFE    imports/attribute access rejected
  V2-11  AST EXECUTOR             code inspection returns structured JSON
  V2-12  EXACT VERIFIER           numeric equality with tolerance
  V2-13  STRUCTURAL VERIFIER      JSON structure validation
  V2-14  EXIT CODE VERIFIER       exit code checking
  V2-15  SHADOW RUNNER E2E        compile + run produces filled plan
  V2-16  SHADOW COST ESTIMATE     executor cost < model cost
  V2-17  FALLBACK ON FAILURE      failed executor records fallback
  V2-18  SHADOW STATS             stats track success/fallback rates
  V2-19  NO PRODUCTION CHANGE     shadow runner has no side effects
  V2-20  GATE METRICS             decomposed_nodes and verifier coverage measurable
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

REPO = Path(__file__).parent.parent
sys.path.insert(0, str(REPO))

from entroly.ravs.compiler import (
    NodeKind,
    Plan,
    PlanCompiler,
    PlanNode,
    detect_substeps,
)
from entroly.ravs.executors import (
    ASTExecutor,
    ExecutorRegistry,
    PythonExecutor,
    SymPyExecutor,
)
from entroly.ravs.verifiers import (
    ExactVerifier,
    ExitCodeVerifier,
    StructuralVerifier,
    VerifierRegistry,
)
from entroly.ravs.shadow_runner import ShadowRunner


# ══════════════════════════════════════════════════════════════════════
# V2-01: Compiler basics
# ══════════════════════════════════════════════════════════════════════


def test_v2_01_compiler_produces_plan():
    compiler = PlanCompiler()
    plan = compiler.compile("explain how to refactor this code")
    assert isinstance(plan, Plan)
    assert plan.total_nodes >= 1
    assert len(plan.nodes) == plan.total_nodes


# ══════════════════════════════════════════════════════════════════════
# V2-02: Computation detected
# ══════════════════════════════════════════════════════════════════════


@pytest.mark.parametrize("query", [
    "calculate 2 + 3 * 4",
    "compute the average of 10, 20, 30",
    "what is 100 / 7",
    "solve x^2 - 1 = 0",
    "simplify (x+1)^2 - x^2",
])
def test_v2_02_computation_detected(query):
    nodes = detect_substeps(query)
    kinds = [n.kind for n in nodes]
    assert NodeKind.COMPUTATION.value in kinds


# ══════════════════════════════════════════════════════════════════════
# V2-03: Code inspection detected
# ══════════════════════════════════════════════════════════════════════


@pytest.mark.parametrize("query", [
    "list the functions in this file",
    "what does the function process do",
    "how many classes are there",
    "show me the signature of parse",
])
def test_v2_03_code_inspection_detected(query):
    nodes = detect_substeps(query)
    kinds = [n.kind for n in nodes]
    assert NodeKind.CODE_INSPECTION.value in kinds


# ══════════════════════════════════════════════════════════════════════
# V2-04: Test execution detected
# ══════════════════════════════════════════════════════════════════════


@pytest.mark.parametrize("query", [
    "run the tests",
    "execute pytest",
    "does the test suite pass",
    "run cargo test",
])
def test_v2_04_test_execution_detected(query):
    nodes = detect_substeps(query)
    kinds = [n.kind for n in nodes]
    assert NodeKind.TEST_EXECUTION.value in kinds


# ══════════════════════════════════════════════════════════════════════
# V2-05: Retrieval detected
# ══════════════════════════════════════════════════════════════════════


@pytest.mark.parametrize("query", [
    "according to the documentation, how does auth work",
    "what does the readme say about installation",
    "is it true that the API supports pagination",
])
def test_v2_05_retrieval_detected(query):
    nodes = detect_substeps(query)
    kinds = [n.kind for n in nodes]
    assert NodeKind.RETRIEVAL_CLAIM.value in kinds


# ══════════════════════════════════════════════════════════════════════
# V2-06: Model-bound always present
# ══════════════════════════════════════════════════════════════════════


def test_v2_06_model_bound_always_present():
    for query in ["calculate 2+2", "just chat with me", "run tests"]:
        nodes = detect_substeps(query)
        kinds = [n.kind for n in nodes]
        assert NodeKind.MODEL_BOUND.value in kinds


# ══════════════════════════════════════════════════════════════════════
# V2-07: Plan serializable
# ══════════════════════════════════════════════════════════════════════


def test_v2_07_plan_serializable():
    compiler = PlanCompiler()
    plan = compiler.compile("calculate 2+3 and list functions")
    j = plan.to_json()
    parsed = json.loads(j)
    assert parsed["total_nodes"] == plan.total_nodes
    assert len(parsed["nodes"]) == len(plan.nodes)


# ══════════════════════════════════════════════════════════════════════
# V2-08: SymPy executor
# ══════════════════════════════════════════════════════════════════════


def test_v2_08_sympy_executor():
    executor = SymPyExecutor()
    if not executor.available:
        pytest.skip("sympy not installed")

    result = executor.execute("2 + 3 * 4")
    assert result.succeeded
    assert float(result.result) == 14.0

    result = executor.execute("simplify x**2 - x**2")
    assert result.succeeded
    assert float(result.result) == 0


# ══════════════════════════════════════════════════════════════════════
# V2-09: Python safe executor
# ══════════════════════════════════════════════════════════════════════


def test_v2_09_python_executor():
    executor = PythonExecutor()

    result = executor.execute("2 + 3 * 4")
    assert result.succeeded
    assert result.result == "14"

    result = executor.execute("sqrt(16)")
    assert result.succeeded
    assert float(result.result) == 4.0

    result = executor.execute("calculate 100 / 4")
    assert result.succeeded
    assert float(result.result) == 25.0


# ══════════════════════════════════════════════════════════════════════
# V2-10: Python rejects unsafe
# ══════════════════════════════════════════════════════════════════════


def test_v2_10_python_rejects_unsafe():
    executor = PythonExecutor()

    result = executor.execute("__import__('os').system('ls')")
    assert not result.succeeded

    result = executor.execute("open('/etc/passwd').read()")
    assert not result.succeeded


# ══════════════════════════════════════════════════════════════════════
# V2-11: AST executor
# ══════════════════════════════════════════════════════════════════════


def test_v2_11_ast_executor():
    executor = ASTExecutor()
    source = '''
def hello(name: str) -> str:
    return f"hello {name}"

class Foo:
    def bar(self, x):
        pass
'''
    result = executor.execute("list the functions", source)
    assert result.succeeded
    data = json.loads(result.result)
    assert "hello" in data["functions"]

    result = executor.execute("how many classes", source)
    assert result.succeeded
    data = json.loads(result.result)
    assert data["count"] == 1

    result = executor.execute("signature of hello", source)
    assert result.succeeded
    data = json.loads(result.result)
    assert "name" in data["parameters"]


# ══════════════════════════════════════════════════════════════════════
# V2-12: Exact verifier
# ══════════════════════════════════════════════════════════════════════


def test_v2_12_exact_verifier():
    v = ExactVerifier()

    assert v.verify("14", "14").passed
    assert v.verify("14.0", "14").passed
    assert v.verify("3.14159", "3.14159").passed
    assert not v.verify("14", "15").passed
    assert v.verify('["a","b"]', '["a","b"]').passed


# ══════════════════════════════════════════════════════════════════════
# V2-13: Structural verifier
# ══════════════════════════════════════════════════════════════════════


def test_v2_13_structural_verifier():
    v = StructuralVerifier()

    # Valid structure
    result = v.verify('{"functions": ["foo"], "count": 1}', ["functions", "count"])
    assert result.passed

    # Missing key
    result = v.verify('{"functions": ["foo"]}', ["functions", "count"])
    assert not result.passed

    # Not JSON
    result = v.verify("not json")
    assert not result.passed


# ══════════════════════════════════════════════════════════════════════
# V2-14: Exit code verifier
# ══════════════════════════════════════════════════════════════════════


def test_v2_14_exit_code_verifier():
    v = ExitCodeVerifier()
    assert v.verify('{"exit_code": 0}').passed
    assert not v.verify('{"exit_code": 1}').passed
    assert v.verify("0").passed
    assert not v.verify("1").passed


# ══════════════════════════════════════════════════════════════════════
# V2-15: Shadow runner E2E
# ══════════════════════════════════════════════════════════════════════


def test_v2_15_shadow_runner_e2e():
    runner = ShadowRunner()
    plan = runner.compile_and_run(
        "calculate 2 + 3",
        request_id="test-r1",
        model="gpt-4o-mini",
    )

    assert plan.request_id == "test-r1"
    assert plan.total_nodes >= 2  # computation + model_bound
    assert plan.decomposed_nodes >= 1
    assert plan.baseline_total_cost_usd > 0


# ══════════════════════════════════════════════════════════════════════
# V2-16: Shadow cost estimate
# ══════════════════════════════════════════════════════════════════════


def test_v2_16_shadow_cost_less_than_model():
    runner = ShadowRunner()
    plan = runner.compile_and_run(
        "calculate 100 / 7",
        model="gpt-4o",
    )

    comp_nodes = [
        n for n in plan.nodes
        if n.kind == NodeKind.COMPUTATION.value
    ]
    if comp_nodes and comp_nodes[0].executor_succeeded:
        assert comp_nodes[0].estimated_cost_usd < comp_nodes[0].baseline_model_cost_usd


# ══════════════════════════════════════════════════════════════════════
# V2-17: Fallback on failure
# ══════════════════════════════════════════════════════════════════════


def test_v2_17_fallback_on_failure():
    runner = ShadowRunner()
    # This query triggers computation detection but the expression
    # "the meaning of life" won't parse
    plan = runner.compile_and_run(
        "compute the meaning of life",
        model="gpt-4o-mini",
    )

    comp_nodes = [
        n for n in plan.nodes
        if n.kind == NodeKind.COMPUTATION.value
    ]
    # If computation was detected and failed, it should fall back
    for n in comp_nodes:
        if not n.executor_succeeded:
            assert n.fell_back_to_model


# ══════════════════════════════════════════════════════════════════════
# V2-18: Shadow stats
# ══════════════════════════════════════════════════════════════════════


def test_v2_18_shadow_stats():
    runner = ShadowRunner()
    runner.compile_and_run("calculate 2 + 2", model="gpt-4o-mini")
    runner.compile_and_run("list the functions in this file", model="gpt-4o")

    stats = runner.stats()
    assert stats["total_runs"] == 2
    assert stats["total_nodes_executed"] >= 1
    assert 0 <= stats["executor_success_rate"] <= 1
    assert 0 <= stats["fallback_rate"] <= 1


# ══════════════════════════════════════════════════════════════════════
# V2-19: No production change (shadow is side-effect free)
# ══════════════════════════════════════════════════════════════════════


def test_v2_19_no_production_change():
    """Shadow runner returns a Plan but has no side effects."""
    runner = ShadowRunner()
    plan = runner.compile_and_run("calculate 42 * 2")

    # Plan is a data object — verify it's not mutating anything external
    assert isinstance(plan, Plan)
    assert plan.to_json()  # serializable
    # No files written, no state changed, no network calls


# ══════════════════════════════════════════════════════════════════════
# V2-20: Gate metrics measurable
# ══════════════════════════════════════════════════════════════════════


def test_v2_20_gate_metrics():
    """V2 gate: >= 40% decomposition evidence, >= 50% verifier coverage."""
    runner = ShadowRunner()

    # Run a batch of queries
    queries = [
        "calculate 2 + 3",
        "what is 10 * 5",
        "solve x + 1 = 3",
        "explain refactoring patterns",
        "how do I use async/await",
        "compute average of 1, 2, 3",
        "list the functions in parser.py",
        "run the tests",
        "what does the readme say about config",
        "just write a hello world",
    ]

    plans = [runner.compile_and_run(q) for q in queries]

    total = len(plans)
    decomposed = sum(1 for p in plans if p.decomposed_nodes > 0)
    decomposition_rate = decomposed / total

    # We can MEASURE the gate — that's the requirement
    assert isinstance(decomposition_rate, float)
    assert 0 <= decomposition_rate <= 1

    # Executor coverage among decomposed nodes
    all_decomp_nodes = [
        n for p in plans for n in p.nodes
        if n.kind != NodeKind.MODEL_BOUND.value
    ]
    if all_decomp_nodes:
        executor_coverage = sum(
            1 for n in all_decomp_nodes if n.executor_succeeded
        ) / len(all_decomp_nodes)
        assert isinstance(executor_coverage, float)


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
