"""Tests for the deterministic routing engine.

Covers:
- Hard rules trigger adjudication
- Confidence below threshold triggers adjudication
- Confidence above threshold triggers fast path
- All reasons are assigned correctly
- Property test: same inputs always produce same route (determinism)
- Property test: confidence at exact threshold boundary has deterministic behavior
- Decision storage captures features
"""

from __future__ import annotations

from uuid import uuid4

import pytest
from hypothesis import given, settings
from hypothesis import strategies as st

from services.intelligence_pipeline_v3.routing.reasons import (
    RouteDecision,
    RoutingReason,
)
from services.intelligence_pipeline_v3.routing.router import (
    RoutingEngine,
)
from services.intelligence_pipeline_v3.routing.rules import evaluate_hard_rules
from services.intelligence_pipeline_v3.routing.store import RoutingDecisionStore
from services.intelligence_pipeline_v3.routing.thresholds import (
    DEFAULT_DOCUMENT_THRESHOLDS,
    DEFAULT_EVENT_THRESHOLDS,
    DEFAULT_FALLBACK_THRESHOLD,
    FastPathThresholds,
    evaluate_thresholds,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def engine() -> RoutingEngine:
    return RoutingEngine()


@pytest.fixture
def clean_features() -> dict:
    """Confidence features with no issues — should pass fast path."""
    return {
        "calibrated_confidence": 0.90,
        "evidence_coverage": 0.95,
        "material_fields_present": True,
    }


@pytest.fixture
def clean_markers() -> dict:
    """Ambiguity markers with no issues."""
    return {
        "unresolved_aliases": 0,
        "primary_company_count": 1,
        "contradictory_numeric_facts": False,
        "conflicting_sentiment": False,
        "implied_causal_impact": False,
        "guidance_vs_consensus": False,
        "long_document_cross_chunk": False,
    }


# ---------------------------------------------------------------------------
# 31.1 — Routing reason enums
# ---------------------------------------------------------------------------


class TestRoutingReasonEnums:
    """Test that all required routing reason enums exist and are correct."""

    def test_all_reasons_defined(self):
        expected = {
            "UNRESOLVED_ALIAS",
            "MULTIPLE_PRIMARY_COMPANIES",
            "CONTRADICTORY_NUMERIC_FACTS",
            "CONFLICTING_SENTIMENT",
            "IMPLIED_CAUSAL_IMPACT",
            "GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING",
            "MATERIAL_FIELD_MISSING",
            "EVIDENCE_COVERAGE_BELOW_THRESHOLD",
            "CALIBRATED_CONFIDENCE_BELOW_THRESHOLD",
            "LONG_DOCUMENT_CROSS_CHUNK_RELATION",
            "FAST_PATH_ACCEPTED",
        }
        actual = {r.name for r in RoutingReason}
        assert actual == expected

    def test_route_decision_values(self):
        assert RouteDecision.FAST_PATH.value == "fast_path"
        assert RouteDecision.ADJUDICATION.value == "adjudication"

    def test_reason_string_values_match_names(self):
        """Reason values should be their name for database storage."""
        for reason in RoutingReason:
            assert reason.value == reason.name


# ---------------------------------------------------------------------------
# 31.2 — Hard ambiguity/conflict rules
# ---------------------------------------------------------------------------


class TestHardRules:
    """Test that hard rules correctly trigger adjudication reasons."""

    def test_no_triggers_returns_empty(self, clean_features, clean_markers):
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert result == []

    def test_unresolved_alias_triggers(self, clean_features, clean_markers):
        clean_markers["unresolved_aliases"] = 2
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.UNRESOLVED_ALIAS in result

    def test_multiple_primary_companies_triggers(self, clean_features, clean_markers):
        clean_markers["primary_company_count"] = 3
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.MULTIPLE_PRIMARY_COMPANIES in result

    def test_contradictory_numeric_facts_triggers(self, clean_features, clean_markers):
        clean_markers["contradictory_numeric_facts"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in result

    def test_conflicting_sentiment_triggers(self, clean_features, clean_markers):
        clean_markers["conflicting_sentiment"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.CONFLICTING_SENTIMENT in result

    def test_implied_causal_impact_triggers(self, clean_features, clean_markers):
        clean_markers["implied_causal_impact"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.IMPLIED_CAUSAL_IMPACT in result

    def test_guidance_vs_consensus_triggers(self, clean_features, clean_markers):
        clean_markers["guidance_vs_consensus"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.GUIDANCE_VS_CONSENSUS_REQUIRES_REASONING in result

    def test_material_field_missing_triggers(self, clean_features, clean_markers):
        clean_features["material_fields_present"] = False
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.MATERIAL_FIELD_MISSING in result

    def test_long_document_cross_chunk_triggers(self, clean_features, clean_markers):
        clean_markers["long_document_cross_chunk"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert RoutingReason.LONG_DOCUMENT_CROSS_CHUNK_RELATION in result

    def test_multiple_triggers_accumulate(self, clean_features, clean_markers):
        clean_markers["unresolved_aliases"] = 1
        clean_markers["conflicting_sentiment"] = True
        clean_markers["implied_causal_impact"] = True
        result = evaluate_hard_rules(clean_features, clean_markers)
        assert len(result) == 3
        assert RoutingReason.UNRESOLVED_ALIAS in result
        assert RoutingReason.CONFLICTING_SENTIMENT in result
        assert RoutingReason.IMPLIED_CAUSAL_IMPACT in result

    def test_hard_rules_override_high_confidence(self, clean_markers):
        """Even with perfect confidence, hard rules force adjudication."""
        features = {
            "calibrated_confidence": 1.0,
            "evidence_coverage": 1.0,
            "material_fields_present": True,
        }
        clean_markers["contradictory_numeric_facts"] = True
        result = evaluate_hard_rules(features, clean_markers)
        assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in result


# ---------------------------------------------------------------------------
# 31.3 — Calibrated fast-path thresholds
# ---------------------------------------------------------------------------


class TestThresholds:
    """Test threshold evaluation by document and event type."""

    def test_default_article_threshold(self):
        assert DEFAULT_DOCUMENT_THRESHOLDS["article"] == 0.80

    def test_default_filing_threshold(self):
        assert DEFAULT_DOCUMENT_THRESHOLDS["filing"] == 0.70

    def test_default_transcript_threshold(self):
        assert DEFAULT_DOCUMENT_THRESHOLDS["transcript"] == 0.75

    def test_confidence_above_threshold_is_fast_path(self):
        thresholds = FastPathThresholds()
        result = evaluate_thresholds(0.85, "article", None, thresholds)
        assert result == RouteDecision.FAST_PATH

    def test_confidence_below_threshold_is_adjudication(self):
        thresholds = FastPathThresholds()
        result = evaluate_thresholds(0.75, "article", None, thresholds)
        assert result == RouteDecision.ADJUDICATION

    def test_confidence_at_exact_threshold_is_fast_path(self):
        """Boundary: confidence == threshold passes fast path."""
        thresholds = FastPathThresholds()
        result = evaluate_thresholds(0.80, "article", None, thresholds)
        assert result == RouteDecision.FAST_PATH

    def test_event_type_overrides_document_type(self):
        thresholds = FastPathThresholds()
        # guidance_change has threshold 0.65, article has 0.80
        # With event_type, the event threshold should apply
        result = evaluate_thresholds(0.70, "article", "guidance_change", thresholds)
        assert result == RouteDecision.FAST_PATH

    def test_unknown_document_type_uses_fallback(self):
        thresholds = FastPathThresholds()
        result = evaluate_thresholds(0.79, "unknown_type", None, thresholds)
        assert result == RouteDecision.ADJUDICATION  # fallback is 0.80

    def test_unknown_event_type_falls_through_to_document(self):
        thresholds = FastPathThresholds()
        # Unknown event, known document type
        result = evaluate_thresholds(0.72, "filing", "unknown_event", thresholds)
        assert result == RouteDecision.FAST_PATH  # filing threshold is 0.70

    def test_custom_thresholds(self):
        thresholds = FastPathThresholds(
            document_thresholds={"custom_doc": 0.50},
            event_thresholds={"custom_event": 0.30},
            fallback_threshold=0.90,
        )
        assert evaluate_thresholds(0.50, "custom_doc", None, thresholds) == RouteDecision.FAST_PATH
        assert evaluate_thresholds(0.49, "custom_doc", None, thresholds) == RouteDecision.ADJUDICATION
        assert evaluate_thresholds(0.30, "other", "custom_event", thresholds) == RouteDecision.FAST_PATH

    def test_resolve_threshold_priority(self):
        thresholds = FastPathThresholds()
        # Event type takes priority
        threshold = thresholds.resolve_threshold("article", "earnings_beat")
        assert threshold == DEFAULT_EVENT_THRESHOLDS["earnings_beat"]

        # Document type when no event
        threshold = thresholds.resolve_threshold("article", None)
        assert threshold == DEFAULT_DOCUMENT_THRESHOLDS["article"]

        # Fallback for unknown
        threshold = thresholds.resolve_threshold("mystery", None)
        assert threshold == DEFAULT_FALLBACK_THRESHOLD


# ---------------------------------------------------------------------------
# 31.4 — Store every route decision and feature snapshot
# ---------------------------------------------------------------------------


class TestRoutingDecisionStore:
    """Test that decisions are stored with full feature snapshots."""

    def test_store_and_retrieve_by_pipeline_run(self, engine, clean_features, clean_markers):
        store = RoutingDecisionStore()
        run_id = uuid4()
        doc_id = uuid4()

        decision = engine.route(
            pipeline_run_id=run_id,
            document_id=doc_id,
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        store.store(decision)

        retrieved = store.get_by_pipeline_run(run_id)
        assert len(retrieved) == 1
        assert retrieved[0].id == decision.id

    def test_decision_captures_confidence_snapshot(self, engine, clean_features, clean_markers):
        run_id = uuid4()
        doc_id = uuid4()

        decision = engine.route(
            pipeline_run_id=run_id,
            document_id=doc_id,
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )

        assert "confidence_features" in decision.confidence_snapshot
        assert "ambiguity_markers" in decision.confidence_snapshot
        assert "thresholds_version" in decision.confidence_snapshot
        assert decision.confidence_snapshot["confidence_features"] == clean_features
        assert decision.confidence_snapshot["ambiguity_markers"] == clean_markers

    def test_store_multiple_decisions_same_run(self, engine, clean_features, clean_markers):
        store = RoutingDecisionStore()
        run_id = uuid4()

        for _ in range(3):
            decision = engine.route(
                pipeline_run_id=run_id,
                document_id=uuid4(),
                confidence_features=clean_features,
                ambiguity_markers=clean_markers,
                document_type="article",
            )
            store.store(decision)

        assert len(store.get_by_pipeline_run(run_id)) == 3
        assert store.count() == 3

    def test_get_by_unknown_run_returns_empty(self):
        store = RoutingDecisionStore()
        assert store.get_by_pipeline_run(uuid4()) == []

    def test_decision_has_timestamp(self, engine, clean_features, clean_markers):
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        assert decision.decided_at is not None
        assert decision.decided_at.tzinfo is not None  # UTC-aware

    def test_decision_is_immutable(self, engine, clean_features, clean_markers):
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        with pytest.raises(Exception):  # frozen dataclass
            decision.route = RouteDecision.ADJUDICATION  # type: ignore[misc]


# ---------------------------------------------------------------------------
# Integration: full routing engine
# ---------------------------------------------------------------------------


class TestRoutingEngine:
    """Integration tests for the full routing path."""

    def test_clean_document_gets_fast_path(self, engine, clean_features, clean_markers):
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        assert decision.route == RouteDecision.FAST_PATH
        assert RoutingReason.FAST_PATH_ACCEPTED in decision.reasons

    def test_hard_rule_forces_adjudication(self, engine, clean_features, clean_markers):
        clean_markers["contradictory_numeric_facts"] = True
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=clean_features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        assert decision.route == RouteDecision.ADJUDICATION
        assert RoutingReason.CONTRADICTORY_NUMERIC_FACTS in decision.reasons

    def test_low_confidence_triggers_adjudication(self, engine, clean_markers):
        features = {
            "calibrated_confidence": 0.50,
            "evidence_coverage": 0.95,
            "material_fields_present": True,
        }
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        assert decision.route == RouteDecision.ADJUDICATION
        assert RoutingReason.CALIBRATED_CONFIDENCE_BELOW_THRESHOLD in decision.reasons

    def test_low_evidence_coverage_triggers_adjudication(self, engine, clean_markers):
        features = {
            "calibrated_confidence": 0.95,
            "evidence_coverage": 0.30,
            "material_fields_present": True,
        }
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=features,
            ambiguity_markers=clean_markers,
            document_type="article",
        )
        assert decision.route == RouteDecision.ADJUDICATION
        assert RoutingReason.EVIDENCE_COVERAGE_BELOW_THRESHOLD in decision.reasons

    def test_hard_rules_take_priority_over_threshold(self, engine):
        """Hard rules short-circuit — threshold is not even evaluated."""
        features = {
            "calibrated_confidence": 0.95,
            "evidence_coverage": 0.95,
            "material_fields_present": True,
        }
        markers = {
            "unresolved_aliases": 1,
            "primary_company_count": 1,
            "contradictory_numeric_facts": False,
            "conflicting_sentiment": False,
            "implied_causal_impact": False,
            "guidance_vs_consensus": False,
            "long_document_cross_chunk": False,
        }
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=features,
            ambiguity_markers=markers,
            document_type="article",
        )
        assert decision.route == RouteDecision.ADJUDICATION
        assert RoutingReason.UNRESOLVED_ALIAS in decision.reasons
        # Should NOT contain threshold reason since hard rules short-circuited
        assert RoutingReason.CALIBRATED_CONFIDENCE_BELOW_THRESHOLD not in decision.reasons

    def test_event_type_affects_threshold(self, engine, clean_markers):
        """Filing with merger event gets easier threshold (0.60)."""
        features = {
            "calibrated_confidence": 0.62,
            "evidence_coverage": 0.80,
            "material_fields_present": True,
        }
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=features,
            ambiguity_markers=clean_markers,
            document_type="filing",
            event_type="merger_acquisition",
        )
        assert decision.route == RouteDecision.FAST_PATH


# ---------------------------------------------------------------------------
# 31.5 — Property tests for determinism and threshold boundaries
# ---------------------------------------------------------------------------


# Strategy for generating valid confidence features
confidence_features_strategy = st.fixed_dictionaries({
    "calibrated_confidence": st.floats(min_value=0.0, max_value=1.0),
    "evidence_coverage": st.floats(min_value=0.0, max_value=1.0),
    "material_fields_present": st.booleans(),
})

# Strategy for generating ambiguity markers
ambiguity_markers_strategy = st.fixed_dictionaries({
    "unresolved_aliases": st.integers(min_value=0, max_value=10),
    "primary_company_count": st.integers(min_value=0, max_value=5),
    "contradictory_numeric_facts": st.booleans(),
    "conflicting_sentiment": st.booleans(),
    "implied_causal_impact": st.booleans(),
    "guidance_vs_consensus": st.booleans(),
    "long_document_cross_chunk": st.booleans(),
})

document_type_strategy = st.sampled_from(
    ["article", "filing", "transcript", "press_release", "macro_event", "unknown"]
)

event_type_strategy = st.one_of(
    st.none(),
    st.sampled_from([
        "earnings_beat", "earnings_miss", "guidance_change",
        "management_change", "merger_acquisition", "regulatory_action",
        "product_launch", "legal_action", "rating_change", "supply_chain",
        "unknown_event",
    ]),
)


class TestDeterminismProperty:
    """Property test: same inputs always produce the same route.

    **Validates: Requirements 10.5**
    """

    @settings(max_examples=100)
    @given(
        confidence_features=confidence_features_strategy,
        ambiguity_markers=ambiguity_markers_strategy,
        document_type=document_type_strategy,
        event_type=event_type_strategy,
    )
    def test_same_inputs_always_same_route(
        self,
        confidence_features: dict,
        ambiguity_markers: dict,
        document_type: str,
        event_type: str | None,
    ):
        """Route decisions are deterministic: same inputs → same output."""
        engine = RoutingEngine()
        run_id = uuid4()
        doc_id = uuid4()

        decision_1 = engine.route(
            pipeline_run_id=run_id,
            document_id=doc_id,
            confidence_features=confidence_features,
            ambiguity_markers=ambiguity_markers,
            document_type=document_type,
            event_type=event_type,
        )
        decision_2 = engine.route(
            pipeline_run_id=run_id,
            document_id=doc_id,
            confidence_features=confidence_features,
            ambiguity_markers=ambiguity_markers,
            document_type=document_type,
            event_type=event_type,
        )

        assert decision_1.route == decision_2.route
        assert decision_1.reasons == decision_2.reasons

    @settings(max_examples=100)
    @given(
        confidence_features=confidence_features_strategy,
        ambiguity_markers=ambiguity_markers_strategy,
        document_type=document_type_strategy,
        event_type=event_type_strategy,
    )
    def test_route_is_always_valid_enum(
        self,
        confidence_features: dict,
        ambiguity_markers: dict,
        document_type: str,
        event_type: str | None,
    ):
        """Route decision is always a valid RouteDecision enum value."""
        engine = RoutingEngine()
        decision = engine.route(
            pipeline_run_id=uuid4(),
            document_id=uuid4(),
            confidence_features=confidence_features,
            ambiguity_markers=ambiguity_markers,
            document_type=document_type,
            event_type=event_type,
        )
        assert decision.route in (RouteDecision.FAST_PATH, RouteDecision.ADJUDICATION)
        assert len(decision.reasons) > 0


class TestThresholdBoundaryProperty:
    """Property test: confidence at exact threshold boundary is deterministic.

    **Validates: Requirements 10.5, 11.6**
    """

    @settings(max_examples=100)
    @given(
        document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
    )
    def test_at_threshold_is_always_fast_path(self, document_type: str):
        """Confidence exactly at threshold always results in fast path."""
        thresholds = FastPathThresholds()
        threshold_value = thresholds.resolve_threshold(document_type, None)

        # At the boundary
        result = evaluate_thresholds(threshold_value, document_type, None, thresholds)
        assert result == RouteDecision.FAST_PATH

    @settings(max_examples=100)
    @given(
        document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
        epsilon=st.floats(min_value=1e-15, max_value=0.1),
    )
    def test_below_threshold_is_always_adjudication(
        self, document_type: str, epsilon: float
    ):
        """Confidence below threshold always results in adjudication."""
        thresholds = FastPathThresholds()
        threshold_value = thresholds.resolve_threshold(document_type, None)
        below = threshold_value - epsilon

        if below >= 0.0:
            result = evaluate_thresholds(below, document_type, None, thresholds)
            assert result == RouteDecision.ADJUDICATION

    @settings(max_examples=100)
    @given(
        document_type=st.sampled_from(list(DEFAULT_DOCUMENT_THRESHOLDS.keys())),
        epsilon=st.floats(min_value=1e-15, max_value=0.1),
    )
    def test_above_threshold_is_always_fast_path(
        self, document_type: str, epsilon: float
    ):
        """Confidence above threshold always results in fast path."""
        thresholds = FastPathThresholds()
        threshold_value = thresholds.resolve_threshold(document_type, None)
        above = threshold_value + epsilon

        if above <= 1.0:
            result = evaluate_thresholds(above, document_type, None, thresholds)
            assert result == RouteDecision.FAST_PATH
