"""Tests for the policy engine — condition evaluation, rule matching, and YAML serialization.

Covers:
- Condition.evaluate() — all 7 operators
- PolicyEngine.evaluate() — priority ordering, shadow rules, project scoping
- PolicyEngine.evaluate_all() — returns all matching decisions
- PolicyEngine.from_yaml() / to_yaml() — YAML round-trip
- PolicyEngine.add_rule() / remove_rule() — dynamic rule management
- EvalContext.field_value() — field resolution including metadata
- PolicyDecision.default_allow() — fallback behavior
"""
from __future__ import annotations

import pytest
import yaml

from greymatter_core.policy import (
    Condition,
    EvalContext,
    PolicyDecision,
    PolicyEngine,
    PolicyOutcome,
    PolicyRule,
)


# ---------------------------------------------------------------------------
# Condition.evaluate — all operators
# ---------------------------------------------------------------------------


class TestConditionEvaluate:
    """Unit tests for individual condition operators."""

    def test_contains_match(self):
        c = Condition(field="content", operator="contains", value="secret")
        assert c.evaluate("this has a secret inside") is True

    def test_contains_no_match(self):
        c = Condition(field="content", operator="contains", value="secret")
        assert c.evaluate("nothing here") is False

    def test_not_contains_match(self):
        c = Condition(field="content", operator="not_contains", value="bad")
        assert c.evaluate("all good") is True

    def test_not_contains_no_match(self):
        c = Condition(field="content", operator="not_contains", value="bad")
        assert c.evaluate("this is bad") is False

    def test_matches_regex(self):
        c = Condition(field="content", operator="matches", value=r"\b\d{3}-\d{4}\b")
        assert c.evaluate("call 555-1234 now") is True

    def test_matches_regex_no_match(self):
        c = Condition(field="content", operator="matches", value=r"\b\d{3}-\d{4}\b")
        assert c.evaluate("no phone here") is False

    def test_matches_invalid_regex(self):
        c = Condition(field="content", operator="matches", value="[invalid")
        assert c.evaluate("anything") is False

    def test_equals_match(self):
        c = Condition(field="tool", operator="equals", value="gm_observe")
        assert c.evaluate("gm_observe") is True

    def test_equals_no_match(self):
        c = Condition(field="tool", operator="equals", value="gm_observe")
        assert c.evaluate("gm_knowledge") is False

    def test_starts_with_match(self):
        c = Condition(field="tool", operator="starts_with", value="gm_")
        assert c.evaluate("gm_observe") is True

    def test_starts_with_no_match(self):
        c = Condition(field="tool", operator="starts_with", value="gm_")
        assert c.evaluate("other_tool") is False

    def test_greater_than_match(self):
        c = Condition(field="score", operator="greater_than", value="0.8")
        assert c.evaluate("0.9") is True

    def test_greater_than_no_match(self):
        c = Condition(field="score", operator="greater_than", value="0.8")
        assert c.evaluate("0.5") is False

    def test_greater_than_equal_is_false(self):
        c = Condition(field="score", operator="greater_than", value="0.8")
        assert c.evaluate("0.8") is False

    def test_greater_than_non_numeric(self):
        c = Condition(field="score", operator="greater_than", value="0.8")
        assert c.evaluate("not_a_number") is False

    def test_less_than_match(self):
        c = Condition(field="score", operator="less_than", value="0.3")
        assert c.evaluate("0.1") is True

    def test_less_than_no_match(self):
        c = Condition(field="score", operator="less_than", value="0.3")
        assert c.evaluate("0.5") is False

    def test_less_than_non_numeric(self):
        c = Condition(field="score", operator="less_than", value="0.3")
        assert c.evaluate("abc") is False

    def test_unknown_operator_returns_false(self):
        c = Condition(field="x", operator="between", value="1")
        assert c.evaluate("anything") is False


# ---------------------------------------------------------------------------
# EvalContext.field_value
# ---------------------------------------------------------------------------


class TestEvalContext:
    """Tests for EvalContext field resolution."""

    def test_named_fields(self):
        ctx = EvalContext(
            tool="gm_observe",
            action="create",
            content="some text",
            project_id="proj-1",
        )
        assert ctx.field_value("tool") == "gm_observe"
        assert ctx.field_value("action") == "create"
        assert ctx.field_value("content") == "some text"
        assert ctx.field_value("project_id") == "proj-1"

    def test_metadata_fallback(self):
        ctx = EvalContext(metadata={"custom_field": "custom_value"})
        assert ctx.field_value("custom_field") == "custom_value"

    def test_unknown_field_empty_string(self):
        ctx = EvalContext()
        assert ctx.field_value("nonexistent") == ""


# ---------------------------------------------------------------------------
# PolicyDecision.default_allow
# ---------------------------------------------------------------------------


class TestPolicyDecision:
    """Tests for PolicyDecision."""

    def test_default_allow(self):
        d = PolicyDecision.default_allow()
        assert d.outcome == PolicyOutcome.ALLOW
        assert d.rule_id == "_default"
        assert d.shadow is False


# ---------------------------------------------------------------------------
# PolicyEngine.evaluate — core matching logic
# ---------------------------------------------------------------------------


class TestPolicyEngineEvaluate:
    """Tests for the main evaluate() method."""

    def test_no_rules_returns_default_allow(self):
        engine = PolicyEngine()
        ctx = EvalContext(tool="gm_observe", action="create")
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ALLOW
        assert decision.rule_id == "_default"

    def test_single_matching_rule(self):
        rule = PolicyRule(
            rule_id="block-secrets",
            description="Block content with secrets",
            outcome=PolicyOutcome.REFUSE,
            reason="Contains sensitive data",
            conditions=[Condition(field="content", operator="contains", value="password")],
        )
        engine = PolicyEngine([rule])
        ctx = EvalContext(content="my password is hunter2")
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.REFUSE
        assert decision.rule_id == "block-secrets"

    def test_non_matching_rule_falls_through(self):
        rule = PolicyRule(
            rule_id="block-secrets",
            description="Block content with secrets",
            outcome=PolicyOutcome.REFUSE,
            conditions=[Condition(field="content", operator="contains", value="password")],
        )
        engine = PolicyEngine([rule])
        ctx = EvalContext(content="nothing sensitive here")
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ALLOW
        assert decision.rule_id == "_default"

    def test_priority_ordering(self):
        """Lower priority number wins (evaluated first)."""
        low_priority = PolicyRule(
            rule_id="low",
            description="Low priority allow",
            outcome=PolicyOutcome.ALLOW,
            priority=200,
            conditions=[Condition(field="tool", operator="equals", value="gm_observe")],
        )
        high_priority = PolicyRule(
            rule_id="high",
            description="High priority block",
            outcome=PolicyOutcome.REFUSE,
            priority=10,
            conditions=[Condition(field="tool", operator="equals", value="gm_observe")],
        )
        engine = PolicyEngine([low_priority, high_priority])
        ctx = EvalContext(tool="gm_observe")
        decision = engine.evaluate(ctx)
        assert decision.rule_id == "high"
        assert decision.outcome == PolicyOutcome.REFUSE

    def test_multiple_conditions_all_must_match(self):
        """AND logic: all conditions must match."""
        rule = PolicyRule(
            rule_id="and-test",
            description="Both conditions required",
            outcome=PolicyOutcome.REDACT,
            conditions=[
                Condition(field="tool", operator="equals", value="gm_observe"),
                Condition(field="action", operator="equals", value="create"),
            ],
        )
        engine = PolicyEngine([rule])

        # Both match
        ctx_both = EvalContext(tool="gm_observe", action="create")
        assert engine.evaluate(ctx_both).outcome == PolicyOutcome.REDACT

        # Only one matches
        ctx_partial = EvalContext(tool="gm_observe", action="list")
        assert engine.evaluate(ctx_partial).outcome == PolicyOutcome.ALLOW

    def test_empty_conditions_is_catch_all(self):
        """A rule with no conditions matches everything."""
        rule = PolicyRule(
            rule_id="catch-all",
            description="Matches everything",
            outcome=PolicyOutcome.ESCALATE,
            conditions=[],
        )
        engine = PolicyEngine([rule])
        ctx = EvalContext(tool="anything", action="whatever")
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ESCALATE
        assert decision.rule_id == "catch-all"

    def test_disabled_rule_skipped(self):
        rule = PolicyRule(
            rule_id="disabled",
            description="Should be skipped",
            outcome=PolicyOutcome.REFUSE,
            enabled=False,
            conditions=[],
        )
        engine = PolicyEngine([rule])
        ctx = EvalContext()
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ALLOW
        assert decision.rule_id == "_default"

    def test_project_scoping_match(self):
        rule = PolicyRule(
            rule_id="proj-only",
            description="Only for project-alpha",
            outcome=PolicyOutcome.REFUSE,
            projects=["project-alpha"],
            conditions=[],
        )
        engine = PolicyEngine([rule])

        ctx_match = EvalContext(project_id="project-alpha")
        assert engine.evaluate(ctx_match).outcome == PolicyOutcome.REFUSE

        ctx_mismatch = EvalContext(project_id="project-beta")
        assert engine.evaluate(ctx_mismatch).outcome == PolicyOutcome.ALLOW

    def test_shadow_rule_does_not_affect_outcome(self):
        """Shadow rules match but don't determine the decision."""
        shadow = PolicyRule(
            rule_id="shadow-block",
            description="Shadow block",
            outcome=PolicyOutcome.REFUSE,
            priority=1,
            shadow=True,
            conditions=[],
        )
        real = PolicyRule(
            rule_id="real-allow",
            description="Real allow",
            outcome=PolicyOutcome.ALLOW,
            priority=50,
            conditions=[],
        )
        engine = PolicyEngine([shadow, real])
        ctx = EvalContext()
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ALLOW
        assert decision.rule_id == "real-allow"

    def test_modifications_passed_through(self):
        rule = PolicyRule(
            rule_id="rewrite-rule",
            description="Rewrite content",
            outcome=PolicyOutcome.REWRITE,
            conditions=[],
            modifications={"content": "sanitized"},
        )
        engine = PolicyEngine([rule])
        decision = engine.evaluate(EvalContext())
        assert decision.modifications == {"content": "sanitized"}

    def test_first_matching_non_shadow_wins(self):
        """When multiple non-shadow rules match, the first (by priority) wins."""
        r1 = PolicyRule(
            rule_id="first", description="", outcome=PolicyOutcome.REDACT,
            priority=10, conditions=[],
        )
        r2 = PolicyRule(
            rule_id="second", description="", outcome=PolicyOutcome.REFUSE,
            priority=20, conditions=[],
        )
        engine = PolicyEngine([r1, r2])
        decision = engine.evaluate(EvalContext())
        assert decision.rule_id == "first"


# ---------------------------------------------------------------------------
# PolicyEngine.evaluate_all
# ---------------------------------------------------------------------------


class TestPolicyEngineEvaluateAll:
    """Tests for evaluate_all() which returns all matching decisions."""

    def test_returns_all_matches(self):
        r1 = PolicyRule(
            rule_id="a", description="", outcome=PolicyOutcome.ALLOW,
            priority=10, conditions=[],
        )
        r2 = PolicyRule(
            rule_id="b", description="", outcome=PolicyOutcome.REFUSE,
            priority=20, shadow=True, conditions=[],
        )
        engine = PolicyEngine([r1, r2])
        decisions = engine.evaluate_all(EvalContext())
        assert len(decisions) == 2
        ids = {d.rule_id for d in decisions}
        assert ids == {"a", "b"}

    def test_skips_disabled_and_project_mismatch(self):
        r1 = PolicyRule(
            rule_id="enabled", description="", outcome=PolicyOutcome.ALLOW,
            conditions=[],
        )
        r2 = PolicyRule(
            rule_id="disabled", description="", outcome=PolicyOutcome.REFUSE,
            enabled=False, conditions=[],
        )
        r3 = PolicyRule(
            rule_id="wrong-project", description="", outcome=PolicyOutcome.REFUSE,
            projects=["other"], conditions=[],
        )
        engine = PolicyEngine([r1, r2, r3])
        decisions = engine.evaluate_all(EvalContext(project_id="mine"))
        assert len(decisions) == 1
        assert decisions[0].rule_id == "enabled"


# ---------------------------------------------------------------------------
# PolicyEngine.add_rule / remove_rule
# ---------------------------------------------------------------------------


class TestRuleManagement:
    """Tests for dynamic rule add/remove."""

    def test_add_rule_maintains_priority_order(self):
        engine = PolicyEngine()
        engine.add_rule(PolicyRule(
            rule_id="low", description="", outcome=PolicyOutcome.ALLOW, priority=100,
        ))
        engine.add_rule(PolicyRule(
            rule_id="high", description="", outcome=PolicyOutcome.REFUSE, priority=10,
        ))
        assert engine.rules[0].rule_id == "high"
        assert engine.rules[1].rule_id == "low"

    def test_remove_rule_returns_true_on_success(self):
        engine = PolicyEngine([
            PolicyRule(rule_id="target", description="", outcome=PolicyOutcome.ALLOW),
        ])
        assert engine.remove_rule("target") is True
        assert len(engine.rules) == 0

    def test_remove_rule_returns_false_on_miss(self):
        engine = PolicyEngine()
        assert engine.remove_rule("nonexistent") is False


# ---------------------------------------------------------------------------
# YAML serialization round-trip
# ---------------------------------------------------------------------------


class TestYAMLSerialization:
    """Tests for from_yaml() and to_yaml()."""

    SAMPLE_YAML = """\
rules:
  - rule_id: block-pii
    description: Block PII in content
    outcome: refuse
    priority: 10
    reason: Content contains PII
    conditions:
      - field: content
        operator: matches
        value: "\\\\b\\\\d{3}-\\\\d{2}-\\\\d{4}\\\\b"
    projects:
      - project-alpha
    shadow: false
    enabled: true
  - rule_id: shadow-audit
    description: Audit all creates
    outcome: allow
    priority: 50
    reason: Audit trail
    conditions:
      - field: action
        operator: equals
        value: create
    shadow: true
"""

    def test_from_yaml_loads_rules(self):
        engine = PolicyEngine.from_yaml(self.SAMPLE_YAML)
        assert len(engine.rules) == 2
        # Should be sorted by priority
        assert engine.rules[0].rule_id == "block-pii"
        assert engine.rules[0].priority == 10
        assert engine.rules[1].rule_id == "shadow-audit"
        assert engine.rules[1].shadow is True

    def test_from_yaml_conditions_parsed(self):
        engine = PolicyEngine.from_yaml(self.SAMPLE_YAML)
        rule = engine.rules[0]
        assert len(rule.conditions) == 1
        assert rule.conditions[0].field == "content"
        assert rule.conditions[0].operator == "matches"

    def test_from_yaml_empty_input(self):
        engine = PolicyEngine.from_yaml("")
        assert len(engine.rules) == 0

    def test_from_yaml_no_rules_key(self):
        engine = PolicyEngine.from_yaml("version: 1")
        assert len(engine.rules) == 0

    def test_to_yaml_roundtrip_preserves_structure(self):
        """from_yaml -> to_yaml -> from_yaml should yield equivalent rules."""
        engine1 = PolicyEngine.from_yaml(self.SAMPLE_YAML)
        exported = engine1.to_yaml()
        engine2 = PolicyEngine.from_yaml(exported)

        assert len(engine2.rules) == len(engine1.rules)
        for r1, r2 in zip(engine1.rules, engine2.rules):
            assert r1.rule_id == r2.rule_id
            assert r1.outcome == r2.outcome
            assert r1.priority == r2.priority
            assert r1.shadow == r2.shadow
            assert len(r1.conditions) == len(r2.conditions)

    def test_to_yaml_produces_valid_yaml(self):
        engine = PolicyEngine([
            PolicyRule(
                rule_id="test",
                description="A test rule",
                outcome=PolicyOutcome.REDACT,
                priority=50,
                reason="test reason",
                conditions=[Condition(field="tool", operator="equals", value="gm_observe")],
            ),
        ])
        output = engine.to_yaml()
        parsed = yaml.safe_load(output)
        assert "rules" in parsed
        assert len(parsed["rules"]) == 1
        assert parsed["rules"][0]["rule_id"] == "test"
        assert parsed["rules"][0]["outcome"] == "redact"

    def test_to_yaml_omits_defaults(self):
        """Shadow=False, enabled=True, empty lists should not appear in YAML."""
        engine = PolicyEngine([
            PolicyRule(
                rule_id="minimal",
                description="Minimal rule",
                outcome=PolicyOutcome.ALLOW,
                reason="",
            ),
        ])
        output = engine.to_yaml()
        parsed = yaml.safe_load(output)
        rule_data = parsed["rules"][0]
        assert "shadow" not in rule_data
        assert "enabled" not in rule_data
        assert "conditions" not in rule_data
        assert "projects" not in rule_data
        assert "modifications" not in rule_data

    def test_to_yaml_includes_modifications(self):
        engine = PolicyEngine([
            PolicyRule(
                rule_id="rewrite",
                description="",
                outcome=PolicyOutcome.REWRITE,
                modifications={"content": "replaced"},
            ),
        ])
        output = engine.to_yaml()
        parsed = yaml.safe_load(output)
        assert parsed["rules"][0]["modifications"] == {"content": "replaced"}


# ---------------------------------------------------------------------------
# PolicyOutcome enum
# ---------------------------------------------------------------------------


class TestPolicyOutcome:
    """Tests for the PolicyOutcome enum values."""

    def test_all_outcomes_exist(self):
        expected = {"allow", "rewrite", "redact", "escalate", "refuse"}
        actual = {o.value for o in PolicyOutcome}
        assert actual == expected

    def test_string_coercion(self):
        assert str(PolicyOutcome.ALLOW) == "PolicyOutcome.ALLOW"
        assert PolicyOutcome.REFUSE.value == "refuse"


# ---------------------------------------------------------------------------
# Integration: realistic policy scenarios
# ---------------------------------------------------------------------------


class TestPolicyScenarios:
    """Integration tests with realistic policy configurations."""

    def test_pii_blocking_with_regex(self):
        """SSN pattern in content triggers refuse."""
        engine = PolicyEngine([
            PolicyRule(
                rule_id="block-ssn",
                description="Block SSN patterns",
                outcome=PolicyOutcome.REFUSE,
                reason="SSN detected",
                conditions=[
                    Condition(field="content", operator="matches", value=r"\b\d{3}-\d{2}-\d{4}\b"),
                ],
            ),
        ])
        ctx_ssn = EvalContext(content="SSN is 123-45-6789")
        assert engine.evaluate(ctx_ssn).outcome == PolicyOutcome.REFUSE

        ctx_clean = EvalContext(content="No PII here")
        assert engine.evaluate(ctx_clean).outcome == PolicyOutcome.ALLOW

    def test_tool_action_scoping(self):
        """Block destructive actions on specific tools."""
        engine = PolicyEngine([
            PolicyRule(
                rule_id="block-delete",
                description="Block deletion on observations",
                outcome=PolicyOutcome.REFUSE,
                conditions=[
                    Condition(field="tool", operator="equals", value="gm_observe"),
                    Condition(field="action", operator="equals", value="delete"),
                ],
            ),
        ])
        # Delete blocked
        ctx_del = EvalContext(tool="gm_observe", action="delete")
        assert engine.evaluate(ctx_del).outcome == PolicyOutcome.REFUSE

        # Create allowed
        ctx_create = EvalContext(tool="gm_observe", action="create")
        assert engine.evaluate(ctx_create).outcome == PolicyOutcome.ALLOW

        # Different tool delete allowed
        ctx_other = EvalContext(tool="gm_knowledge", action="delete")
        assert engine.evaluate(ctx_other).outcome == PolicyOutcome.ALLOW

    def test_shadow_monitoring_with_real_enforcement(self):
        """Shadow rules monitor alongside real enforcement rules."""
        engine = PolicyEngine([
            PolicyRule(
                rule_id="shadow-monitor",
                description="Monitor all creates",
                outcome=PolicyOutcome.ESCALATE,
                priority=1,
                shadow=True,
                conditions=[Condition(field="action", operator="equals", value="create")],
            ),
            PolicyRule(
                rule_id="allow-creates",
                description="Allow creates normally",
                outcome=PolicyOutcome.ALLOW,
                priority=50,
                conditions=[Condition(field="action", operator="equals", value="create")],
            ),
        ])
        ctx = EvalContext(action="create")

        # evaluate returns the real rule
        decision = engine.evaluate(ctx)
        assert decision.outcome == PolicyOutcome.ALLOW
        assert decision.rule_id == "allow-creates"

        # evaluate_all returns both
        all_decisions = engine.evaluate_all(ctx)
        assert len(all_decisions) == 2
        shadow = next(d for d in all_decisions if d.shadow)
        assert shadow.rule_id == "shadow-monitor"

    def test_yaml_loaded_engine_evaluates_correctly(self):
        """Engine loaded from YAML should evaluate correctly."""
        yaml_text = """\
rules:
  - rule_id: block-tool
    description: Block specific tool
    outcome: refuse
    priority: 10
    reason: Tool not allowed
    conditions:
      - field: tool
        operator: equals
        value: dangerous_tool
"""
        engine = PolicyEngine.from_yaml(yaml_text)
        ctx_block = EvalContext(tool="dangerous_tool")
        assert engine.evaluate(ctx_block).outcome == PolicyOutcome.REFUSE

        ctx_allow = EvalContext(tool="safe_tool")
        assert engine.evaluate(ctx_allow).outcome == PolicyOutcome.ALLOW
