"""Tests for the explain_decision function."""

import pytest
from highflame_policy.engine import Decision, DeterminingPolicy
from highflame_policy.explain import (
    ExplainedDecision,
    PolicyExplanation,
    ConditionResult,
    EvaluatedExpression,
    EvidenceSource,
    explain_decision,
    evaluate_expression,
)
from highflame_policy.rule import PolicyRule, PolicyCondition, ConditionExpression


def make_rule(
    id: str,
    effect: str = "forbid",
    action: str = "process_prompt",
    conditions: list[PolicyCondition] | None = None,
    raw_condition: str | None = None,
    condition_expression: ConditionExpression | None = None,
) -> PolicyRule:
    """Helper to create a minimal PolicyRule."""
    rule: PolicyRule = {
        "annotations": {"id": id, "name": id},
        "effect": effect,
        "action": action,
        "conditions": conditions or [],
        "enabled": True,
        "order": 0,
    }
    if raw_condition:
        rule["rawCondition"] = raw_condition
    if condition_expression:
        rule["conditionExpression"] = condition_expression
    return rule


def make_decision(effect: str, policy_ids: list[str]) -> Decision:
    """Helper to create a minimal Decision."""
    return Decision(
        effect=effect,
        determining_policies=[
            DeterminingPolicy(id=pid, annotations={"id": pid, "name": pid})
            for pid in policy_ids
        ],
    )


class TestExplainDecision:
    def test_single_permit_eq(self):
        rules = [make_rule("allow-prod", effect="permit", action="read_file",
                           conditions=[{"field": "environment", "operator": "eq", "value": "production"}])]
        decision = make_decision("Allow", ["allow-prod"])
        context = {"environment": "production"}

        result = explain_decision(decision, rules, context)

        assert result.effect == "Allow"
        assert len(result.explanations) == 1
        assert len(result.unmatched_policies) == 0

        exp = result.explanations[0]
        assert exp.policy_id == "allow-prod"
        assert exp.effect == "permit"
        assert len(exp.condition_results) == 1
        assert exp.condition_results[0].matched is True
        assert exp.condition_results[0].actual == "production"
        assert exp.condition_results[0].expected == "production"

    def test_forbid_gt(self):
        rules = [make_rule("block-threats",
                           conditions=[{"field": "threat_count", "operator": "gt", "value": 5}])]
        decision = make_decision("Deny", ["block-threats"])
        context = {"threat_count": 10}

        result = explain_decision(decision, rules, context)

        assert result.effect == "Deny"
        cr = result.explanations[0].condition_results[0]
        assert cr.matched is True
        assert cr.actual == 10
        assert cr.expected == 5
        assert cr.operator == "gt"

    def test_multiple_conditions_all_match(self):
        rules = [make_rule("multi-cond", conditions=[
            {"field": "threat_count", "operator": "gt", "value": 0},
            {"field": "environment", "operator": "eq", "value": "production"},
            {"field": "tags", "operator": "contains", "value": "security"},
        ])]
        decision = make_decision("Deny", ["multi-cond"])
        context = {"threat_count": 3, "environment": "production", "tags": ["security", "baseline"]}

        result = explain_decision(decision, rules, context)

        assert len(result.explanations[0].condition_results) == 3
        for cr in result.explanations[0].condition_results:
            assert cr.matched is True

    def test_missing_context_field(self):
        rules = [make_rule("missing-field",
                           conditions=[{"field": "nonexistent", "operator": "eq", "value": "foo"}])]
        decision = make_decision("Deny", ["missing-field"])
        context = {}

        result = explain_decision(decision, rules, context)

        cr = result.explanations[0].condition_results[0]
        assert cr.matched is False
        assert cr.actual is None

    def test_raw_condition(self):
        rules = [make_rule("raw-cond", raw_condition='context.path like "/etc/*"')]
        decision = make_decision("Deny", ["raw-cond"])
        context = {"path": "/etc/passwd"}

        result = explain_decision(decision, rules, context)

        exp = result.explanations[0]
        assert len(exp.condition_results) == 0
        assert exp.raw_condition == 'context.path like "/etc/*"'
        assert "raw condition" in exp.summary

    def test_unmatched_policy_id(self):
        rules = [make_rule("known-rule")]
        decision = make_decision("Deny", ["unknown-rule"])
        context = {}

        result = explain_decision(decision, rules, context)

        assert len(result.explanations) == 0
        assert result.unmatched_policies == ["unknown-rule"]

    def test_no_conditions(self):
        rules = [make_rule("no-cond")]
        decision = make_decision("Deny", ["no-cond"])
        context = {}

        result = explain_decision(decision, rules, context)

        exp = result.explanations[0]
        assert len(exp.condition_results) == 0
        assert exp.raw_condition is None
        assert "no conditions" in exp.summary

    def test_like_operator_match(self):
        rules = [make_rule("like-test",
                           conditions=[{"field": "path", "operator": "like", "value": "/etc/*"}])]
        decision = make_decision("Deny", ["like-test"])

        result = explain_decision(decision, rules, {"path": "/etc/passwd"})
        assert result.explanations[0].condition_results[0].matched is True

    def test_like_operator_no_match(self):
        rules = [make_rule("like-test",
                           conditions=[{"field": "path", "operator": "like", "value": "/etc/*"}])]
        decision = make_decision("Deny", ["like-test"])

        result = explain_decision(decision, rules, {"path": "/home/user"})
        assert result.explanations[0].condition_results[0].matched is False

    def test_contains_string(self):
        rules = [make_rule("str-contains",
                           conditions=[{"field": "message", "operator": "contains", "value": "error"}])]
        decision = make_decision("Deny", ["str-contains"])
        context = {"message": "an error occurred"}

        result = explain_decision(decision, rules, context)
        assert result.explanations[0].condition_results[0].matched is True

    def test_contains_array(self):
        rules = [make_rule("arr-contains",
                           conditions=[{"field": "tags", "operator": "contains", "value": "pii"}])]
        decision = make_decision("Deny", ["arr-contains"])
        context = {"tags": ["secrets", "pii", "security"]}

        result = explain_decision(decision, rules, context)
        assert result.explanations[0].condition_results[0].matched is True

    def test_in_operator(self):
        rules = [make_rule("in-test",
                           conditions=[{"field": "environment", "operator": "in", "value": ["production", "staging"]}])]
        decision = make_decision("Deny", ["in-test"])

        result = explain_decision(decision, rules, {"environment": "production"})
        assert result.explanations[0].condition_results[0].matched is True

        result = explain_decision(decision, rules, {"environment": "development"})
        assert result.explanations[0].condition_results[0].matched is False

    def test_type_mismatch(self):
        rules = [make_rule("type-mismatch",
                           conditions=[{"field": "count", "operator": "gt", "value": 5}])]
        decision = make_decision("Deny", ["type-mismatch"])
        context = {"count": "not-a-number"}

        result = explain_decision(decision, rules, context)
        assert result.explanations[0].condition_results[0].matched is False

    def test_multiple_determining_policies(self):
        rules = [
            make_rule("policy-a", conditions=[{"field": "severity", "operator": "eq", "value": "HIGH"}]),
            make_rule("policy-b", conditions=[{"field": "threat_count", "operator": "gt", "value": 0}]),
        ]
        decision = make_decision("Deny", ["policy-a", "policy-b"])
        context = {"severity": "HIGH", "threat_count": 3}

        result = explain_decision(decision, rules, context)

        assert len(result.explanations) == 2
        assert result.explanations[0].policy_id == "policy-a"
        assert result.explanations[1].policy_id == "policy-b"
        assert result.explanations[0].condition_results[0].matched is True
        assert result.explanations[1].condition_results[0].matched is True

    def test_summary(self):
        rules = [make_rule("summary-test", action="process_prompt", conditions=[
            {"field": "threat_count", "operator": "gt", "value": 5},
            {"field": "contains_pii", "operator": "eq", "value": True},
        ])]
        decision = make_decision("Deny", ["summary-test"])
        context = {"threat_count": 10, "contains_pii": True}

        result = explain_decision(decision, rules, context)
        summary = result.explanations[0].summary

        assert "forbid" in summary
        assert "process_prompt" in summary
        assert "threat_count" in summary

    def test_neq_operator(self):
        rules = [make_rule("neq-test",
                           conditions=[{"field": "env", "operator": "neq", "value": "test"}])]
        decision = make_decision("Deny", ["neq-test"])

        result = explain_decision(decision, rules, {"env": "production"})
        assert result.explanations[0].condition_results[0].matched is True

        result = explain_decision(decision, rules, {"env": "test"})
        assert result.explanations[0].condition_results[0].matched is False

    def test_lte_gte_operators(self):
        rules = [make_rule("lte-test", conditions=[
            {"field": "score", "operator": "lte", "value": 100},
            {"field": "min_score", "operator": "gte", "value": 50},
        ])]
        decision = make_decision("Deny", ["lte-test"])
        context = {"score": 100, "min_score": 50}

        result = explain_decision(decision, rules, context)
        assert result.explanations[0].condition_results[0].matched is True  # 100 <= 100
        assert result.explanations[0].condition_results[1].matched is True  # 50 >= 50


class TestEvaluateExpression:
    """Tests for the evaluate_expression function."""

    def test_simple_comparison(self):
        expr: ConditionExpression = {"kind": "comparison", "field": "score", "operator": "gte", "value": 75}
        result = evaluate_expression(expr, {"score": 100})

        assert result["kind"] == "comparison"
        assert result["matched"] is True
        assert result["actual"] == 100
        assert result["expected"] == 75

    def test_has_field_present(self):
        expr: ConditionExpression = {"kind": "has", "field": "mcp_server"}

        assert evaluate_expression(expr, {"mcp_server": "github"})["matched"] is True
        assert evaluate_expression(expr, {})["matched"] is False
        assert evaluate_expression(expr, {"mcp_server": None})["matched"] is False

    def test_contains(self):
        expr: ConditionExpression = {"kind": "contains", "field": "yara_threats", "value": "prompt_injection"}

        assert evaluate_expression(expr, {"yara_threats": ["prompt_injection", "jailbreak"]})["matched"] is True
        assert evaluate_expression(expr, {"yara_threats": ["jailbreak"]})["matched"] is False

    def test_like(self):
        expr: ConditionExpression = {"kind": "like", "field": "path", "pattern": "*.env*"}

        assert evaluate_expression(expr, {"path": "/app/.env.local"})["matched"] is True
        assert evaluate_expression(expr, {"path": "/app/config.json"})["matched"] is False

    def test_and_all_must_match(self):
        expr: ConditionExpression = {
            "kind": "and",
            "children": [
                {"kind": "has", "field": "injection_confidence"},
                {"kind": "comparison", "field": "injection_confidence", "operator": "gte", "value": 75},
            ],
        }

        result = evaluate_expression(expr, {"injection_confidence": 100})
        assert result["matched"] is True
        assert result["children"][0]["matched"] is True  # has
        assert result["children"][1]["matched"] is True  # >= 75

        # Field missing -> has fails -> AND fails
        result2 = evaluate_expression(expr, {})
        assert result2["matched"] is False

    def test_or_any_must_match(self):
        expr: ConditionExpression = {
            "kind": "or",
            "children": [
                {"kind": "comparison", "field": "mcp_server", "operator": "eq", "value": "untrusted-server"},
                {"kind": "comparison", "field": "mcp_server", "operator": "eq", "value": "deprecated-server"},
            ],
        }

        assert evaluate_expression(expr, {"mcp_server": "untrusted-server"})["matched"] is True
        assert evaluate_expression(expr, {"mcp_server": "deprecated-server"})["matched"] is True
        assert evaluate_expression(expr, {"mcp_server": "github"})["matched"] is False

    def test_not(self):
        expr: ConditionExpression = {
            "kind": "not",
            "child": {"kind": "comparison", "field": "verified", "operator": "eq", "value": True},
        }

        assert evaluate_expression(expr, {"verified": False})["matched"] is True
        assert evaluate_expression(expr, {"verified": True})["matched"] is False

    def test_raw_as_not_matched(self):
        expr: ConditionExpression = {"kind": "raw", "text": "some complex expression"}
        assert evaluate_expression(expr, {})["matched"] is False


class TestConditionExpressionExplainDecision:
    """Tests for explainDecision with conditionExpression (Overwatch scenarios)."""

    def test_condition_expression_injection_score(self):
        """Test has + comparison with conditionExpression."""
        rules = [make_rule(
            "semantic-block-injection-score",
            effect="forbid",
            action='Overwatch::Action::"process_prompt"',
            condition_expression={
                "kind": "and",
                "children": [
                    {"kind": "has", "field": "injection_confidence"},
                    {"kind": "comparison", "field": "injection_confidence", "operator": "gte", "value": 75},
                ],
            },
        )]
        decision = make_decision("Deny", ["semantic-block-injection-score"])
        context = {"injection_confidence": 100}

        result = explain_decision(decision, rules, context)

        exp = result.explanations[0]
        assert exp.evaluated_expression is not None
        assert exp.evaluated_expression["matched"] is True

        if exp.evaluated_expression["kind"] == "and":
            comp_node = exp.evaluated_expression["children"][1]
            assert comp_node["kind"] == "comparison"
            assert comp_node["actual"] == 100
            assert comp_node["expected"] == 75
            assert comp_node["matched"] is True

        # condition_results should contain 1 flat leaf (no 'has')
        assert len(exp.condition_results) == 1
        cr = exp.condition_results[0]
        assert cr.field == "injection_confidence"
        assert cr.operator == "gte"
        assert cr.expected == 75
        assert cr.actual == 100
        assert cr.matched is True

        # Summary should mention actual value
        assert "injection_confidence" in exp.summary
        assert "100" in exp.summary
        assert exp.raw_condition is None

    def test_condition_expression_mcp_server_exclusion(self):
        """Test has + OR(eq, eq) - MCP server block."""
        rules = [make_rule(
            "mcp-tool-exclude-server",
            effect="forbid",
            action='Overwatch::Action::"call_tool"',
            condition_expression={
                "kind": "and",
                "children": [
                    {"kind": "has", "field": "mcp_server"},
                    {
                        "kind": "or",
                        "children": [
                            {"kind": "comparison", "field": "mcp_server", "operator": "eq", "value": "untrusted-server"},
                            {"kind": "comparison", "field": "mcp_server", "operator": "eq", "value": "deprecated-server"},
                        ],
                    },
                ],
            },
        )]
        decision = make_decision("Deny", ["mcp-tool-exclude-server"])

        # Blocked server
        result = explain_decision(decision, rules, {"mcp_server": "untrusted-server"})
        expr = result.explanations[0].evaluated_expression
        assert expr is not None
        assert expr["matched"] is True

        if expr["kind"] == "and":
            or_node = expr["children"][1]
            assert or_node["kind"] == "or"
            assert or_node["matched"] is True
            assert or_node["children"][0]["matched"] is True   # untrusted-server matches
            assert or_node["children"][1]["matched"] is False  # deprecated-server doesn't match

        # condition_results: flat list of both OR leaves (no 'has')
        crs = result.explanations[0].condition_results
        assert len(crs) == 2
        assert crs[0].field == "mcp_server"
        assert crs[0].operator == "eq"
        assert crs[0].expected == "untrusted-server"
        assert crs[0].actual == "untrusted-server"
        assert crs[0].matched is True
        assert crs[1].field == "mcp_server"
        assert crs[1].expected == "deprecated-server"
        assert crs[1].matched is False

        assert "mcp_server" in result.explanations[0].summary
        assert "untrusted-server" in result.explanations[0].summary

    def test_condition_expression_yara_threats(self):
        """Test has + contains."""
        rules = [make_rule(
            "semantic-block-injection",
            effect="forbid",
            action='Overwatch::Action::"process_prompt"',
            condition_expression={
                "kind": "and",
                "children": [
                    {"kind": "has", "field": "yara_threats"},
                    {"kind": "contains", "field": "yara_threats", "value": "prompt_injection"},
                ],
            },
        )]
        decision = make_decision("Deny", ["semantic-block-injection"])
        context = {"yara_threats": ["prompt_injection", "jailbreak"]}

        result = explain_decision(decision, rules, context)

        expr = result.explanations[0].evaluated_expression
        assert expr is not None
        assert expr["matched"] is True
        if expr["kind"] == "and":
            assert expr["children"][1]["kind"] == "contains"
            assert expr["children"][1]["matched"] is True
            assert expr["children"][1]["actual"] == ["prompt_injection", "jailbreak"]
            assert expr["children"][1]["expected"] == "prompt_injection"

        # condition_results: flat contains leaf (no 'has')
        crs = result.explanations[0].condition_results
        assert len(crs) == 1
        assert crs[0].field == "yara_threats"
        assert crs[0].operator == "contains"
        assert crs[0].expected == "prompt_injection"
        assert crs[0].actual == ["prompt_injection", "jailbreak"]
        assert crs[0].matched is True

    def test_condition_expression_multi_field(self):
        """Test has + has + contains + comparison (4 children AND)."""
        rules = [make_rule(
            "semantic-block-high-severity",
            effect="forbid",
            condition_expression={
                "kind": "and",
                "children": [
                    {"kind": "has", "field": "threat_categories"},
                    {"kind": "has", "field": "max_threat_severity"},
                    {"kind": "contains", "field": "threat_categories", "value": "semantic"},
                    {"kind": "comparison", "field": "max_threat_severity", "operator": "gte", "value": 3},
                ],
            },
        )]
        decision = make_decision("Deny", ["semantic-block-high-severity"])
        context = {"threat_categories": ["semantic", "injection"], "max_threat_severity": 4}

        result = explain_decision(decision, rules, context)

        expr = result.explanations[0].evaluated_expression
        assert expr is not None
        assert expr["matched"] is True
        if expr["kind"] == "and":
            assert len(expr["children"]) == 4
            assert all(c["matched"] for c in expr["children"])

        # condition_results: 2 leaves (contains + comparison), 'has' nodes excluded
        crs = result.explanations[0].condition_results
        assert len(crs) == 2
        assert crs[0].field == "threat_categories"
        assert crs[0].operator == "contains"
        assert crs[0].actual == ["semantic", "injection"]
        assert crs[1].field == "max_threat_severity"
        assert crs[1].operator == "gte"
        assert crs[1].actual == 4
        assert crs[1].expected == 3

    def test_no_annotations_in_explanation(self):
        """Verify PolicyExplanation does NOT have annotations field."""
        rules = [make_rule("anno-test")]
        decision = make_decision("Deny", ["anno-test"])
        result = explain_decision(decision, rules, {})

        exp = result.explanations[0]
        assert not hasattr(exp, "annotations")
        assert exp.policy_id == "anno-test"


class TestProvenanceTracking:
    """Tests for evidence provenance — linking conditions to source detectors."""

    def test_flat_conditions_with_provenance(self):
        rules = [make_rule("block-injection", conditions=[
            {"field": "injection_score", "operator": "gte", "value": 80},
            {"field": "topic", "operator": "eq", "value": "harmful"},
        ])]
        decision = make_decision("Deny", ["block-injection"])
        context = {"injection_score": 95, "topic": "harmful"}

        provenance = {
            "injection_score": EvidenceSource(detector="injection", latency_ms=42, labels={"tier": "standard"}),
            "topic": EvidenceSource(detector="topic_classifier", latency_ms=15, labels={"tier": "fast"}),
        }

        result = explain_decision(decision, rules, context, provenance=provenance)

        crs = result.explanations[0].condition_results
        assert len(crs) == 2

        assert crs[0].source is not None
        assert crs[0].source.detector == "injection"
        assert crs[0].source.latency_ms == 42
        assert crs[0].source.labels == {"tier": "standard"}

        assert crs[1].source is not None
        assert crs[1].source.detector == "topic_classifier"

    def test_expression_tree_leaf_provenance(self):
        rules = [make_rule("block-high-injection", condition_expression={
            "kind": "and",
            "children": [
                {"kind": "has", "field": "injection_score"},
                {"kind": "comparison", "field": "injection_score", "operator": "gte", "value": 80},
            ],
        })]
        decision = make_decision("Deny", ["block-high-injection"])
        context = {"injection_score": 95}

        provenance = {
            "injection_score": EvidenceSource(detector="injection", latency_ms=42, labels={"tier": "standard"}),
        }

        result = explain_decision(decision, rules, context, provenance=provenance)
        exp = result.explanations[0]

        # Evaluated expression leaf should have source
        assert exp.evaluated_expression is not None
        assert exp.evaluated_expression["kind"] == "and"
        comp_node = exp.evaluated_expression["children"][1]
        assert comp_node["kind"] == "comparison"
        assert "source" in comp_node
        assert comp_node["source"].detector == "injection"

        # has node should NOT have source
        has_node = exp.evaluated_expression["children"][0]
        assert "source" not in has_node

        # Collected condition results should also have source
        assert len(exp.condition_results) == 1
        assert exp.condition_results[0].source is not None
        assert exp.condition_results[0].source.detector == "injection"

    def test_partial_provenance_coverage(self):
        rules = [make_rule("multi-field", conditions=[
            {"field": "injection_score", "operator": "gte", "value": 80},
            {"field": "custom_field", "operator": "eq", "value": "blocked"},
        ])]
        decision = make_decision("Deny", ["multi-field"])
        context = {"injection_score": 95, "custom_field": "blocked"}

        provenance = {
            "injection_score": EvidenceSource(detector="injection", latency_ms=42),
        }

        result = explain_decision(decision, rules, context, provenance=provenance)
        crs = result.explanations[0].condition_results

        assert crs[0].source is not None
        assert crs[0].source.detector == "injection"
        assert crs[1].source is None

    def test_source_none_when_no_provenance(self):
        rules = [make_rule("no-prov", conditions=[
            {"field": "score", "operator": "gte", "value": 50},
        ])]
        decision = make_decision("Deny", ["no-prov"])
        context = {"score": 75}

        result = explain_decision(decision, rules, context)
        assert result.explanations[0].condition_results[0].source is None

    def test_contains_and_like_provenance(self):
        rules = [make_rule("contains-like", condition_expression={
            "kind": "and",
            "children": [
                {"kind": "contains", "field": "threats", "value": "injection"},
                {"kind": "like", "field": "path", "pattern": "/etc/*"},
            ],
        })]
        decision = make_decision("Deny", ["contains-like"])
        context = {"threats": ["injection", "jailbreak"], "path": "/etc/passwd"}

        provenance = {
            "threats": EvidenceSource(detector="yara", latency_ms=10, labels={"tier": "fast"}),
            "path": EvidenceSource(detector="file_scanner", latency_ms=5),
        }

        result = explain_decision(decision, rules, context, provenance=provenance)
        crs = result.explanations[0].condition_results

        assert len(crs) == 2
        assert crs[0].source.detector == "yara"
        assert crs[1].source.detector == "file_scanner"
