"""
Phase 1 Validation: SDK-level decision context validation using Cognext/BASEL fixtures.

Tests that the SDK client emits payloads matching the frozen contract, including:
- Five core decision fields present/absent per fixture
- Shadow vs enforce payload parity
- Resource auto-extraction for BASEL-style tools
- Latency event emission (async shadow, sync enforce)
- Coverage flag correctness

Shadow semantics (current client):
- _shadow_invoke submits _bg_evaluate via ThreadPoolExecutor
- requests.post() is called inside the background thread (NOT self._session.post)
- invoke() returns immediately with decision="pending", _shadow_future=<Future>
- on_event decision callback fires asynchronously when the future completes
"""
from __future__ import annotations

import threading
import pytest
from unittest.mock import Mock, patch, MagicMock
from concurrent.futures import Future

import requests as requests_lib

import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))

from agendex import AgendexClient
from agendex.activity import extract_resources, build_activity_record
from agendex.integrations.decision_capture import (
    DecisionContextBuilder,
    DecisionCapture,
    compute_context_coverage,
    build_transport_status,
    extract_resource_targets,
)
from fixtures_cognext import (
    ALL_FIXTURES,
    CORE_COVERAGE_KEYS,
    DECISION_CONTEXT_REQUIRED_KEYS,
    SUPPORTING_CONTEXT_EXPECTED_KEYS,
    MANAGER_ROUTE_FIXTURE,
    TOOL_CALL_PAYMENT_FIXTURE,
    TOOL_CALL_CRM_LOOKUP_FIXTURE,
    TOOL_CALL_S3_FIXTURE,
    TOOL_CALL_HTTP_FIXTURE,
    THIN_CLIENT_FIXTURE,
)


# ===========================================================================
# Helpers
# ===========================================================================

def _make_client(mode="shadow"):
    with patch.object(AgendexClient, "_fetch_mode", return_value=mode):
        return AgendexClient(
            base_url="http://test:8000",
            agent_id="test_agent",
            token="test_token",
            tenant_id="default",
        )


def _mock_shadow_bg_post(outcome="would_allow", latency_ms=8.5):
    """Return a mock for requests.post used inside _bg_evaluate."""
    mock_resp = Mock()
    mock_resp.ok = True
    mock_resp.status_code = 200
    mock_resp.json = Mock(return_value={
        "decision": {"outcome": outcome, "reason": "fixture test"},
        "request_id": "req-fixture",
        "latency_ms": latency_ms,
    })
    return mock_resp


def _mock_enforce_session(client, outcome="allow"):
    """Wire up a mock session for enforce mode (uses self._session.post)."""
    client._session = Mock()
    mock_eval = Mock()
    mock_eval.ok = True
    mock_eval.status_code = 200
    mock_eval.json = Mock(return_value={
        "decision": {"outcome": outcome, "reason": "fixture test"},
        "token": "scoped-tok-fixture",
        "request_id": "req-fixture-enforce",
        "latency_ms": 12.3,
    })
    mock_eval.raise_for_status = Mock()

    mock_invoke = Mock()
    mock_invoke.ok = True
    mock_invoke.status_code = 200
    mock_invoke.json = Mock(return_value={"success": True, "result": {"ok": True}})
    mock_invoke.raise_for_status = Mock()

    client._session.post = Mock(side_effect=[mock_eval, mock_invoke])
    return client


def _shadow_invoke_and_wait(client, *, on_event=None, **invoke_kwargs):
    """Invoke in shadow mode and wait for the background future to complete."""
    result = client.invoke(on_event=on_event, **invoke_kwargs)
    assert result["_agendex"] == "shadow"
    assert result["decision"] == "pending"
    assert result["execute"] is True
    future = result.get("_shadow_future")
    assert future is not None, "_shadow_future must be present"
    bg_result = future.result(timeout=5)
    return result, bg_result


# ===========================================================================
# Resource auto-extraction tests — BASEL/Cognext shapes
# ===========================================================================

class TestResourceExtraction:
    """Verify auto-extraction produces correct resource_targets for BASEL shapes."""

    def test_payment_resources_from_tool_kwargs(self):
        """Payment extraction requires tool.* prefix on action for _extract_tool_resources."""
        fx = TOOL_CALL_PAYMENT_FIXTURE["invoke"]
        resources = extract_resources(fx["action"], fx["params"])
        # Current SDK: payments.* doesn't trigger tool resource extraction
        # This is a known gap — only tool.* prefix actions extract nested kwargs
        assert resources == [], "non-tool.* actions don't auto-extract kwargs resources"

        # With tool.* prefix, extraction works
        resources_tool = extract_resources("tool.initiate_ach", fx["params"])
        types = [r["type"] for r in resources_tool]
        assert "payment" in types
        payment = next(r for r in resources_tool if r["type"] == "payment")
        assert payment["amount"] == 500
        assert payment["currency"] == "USD"

    def test_crm_database_resource(self):
        fx = TOOL_CALL_CRM_LOOKUP_FIXTURE["invoke"]
        resources = extract_resources(fx["action"], fx["params"])
        assert any(r["type"] == "database" for r in resources)
        db = next(r for r in resources if r["type"] == "database")
        assert db["table"] == "customers"
        assert db["operation"] == "read"

    def test_s3_resource(self):
        fx = TOOL_CALL_S3_FIXTURE["invoke"]
        resources = extract_resources(fx["action"], fx["params"])
        assert any(r["type"] == "s3" for r in resources)
        s3 = next(r for r in resources if r["type"] == "s3")
        assert s3["bucket"] == "compliance-docs"
        assert s3["key_prefix"] == "reports/2026/"

    def test_http_resource(self):
        fx = TOOL_CALL_HTTP_FIXTURE["invoke"]
        resources = extract_resources(fx["action"], fx["params"])
        assert any(r["type"] == "http" for r in resources)
        http = next(r for r in resources if r["type"] == "http")
        assert http["domain"] == "api.bank.example.com"
        assert http["method"] == "GET"

    def test_thin_client_no_resources(self):
        fx = THIN_CLIENT_FIXTURE["invoke"]
        resources = extract_resources(fx["action"], fx["params"])
        assert resources == []


# ===========================================================================
# Activity record building — Cognext shapes
# ===========================================================================

class TestActivityBuildCognext:
    """Verify build_activity_record includes expected fields from Cognext contexts."""

    @pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[f["description"] for f in ALL_FIXTURES])
    def test_activity_record_has_required_fields(self, fixture):
        fx = fixture["invoke"]
        record = build_activity_record(
            agent_id="test-agent",
            action=fx["action"] or "manager.route",
            params=fx["params"],
            task=fx["task"],
            context=fx["context"],
        )
        assert "agent_id" in record
        assert "action" in record
        assert "params" in record
        assert "task" in record
        assert "context" in record
        assert "resources" in record
        assert "timestamp" in record

    def test_payment_activity_captures_user_intent_in_context(self):
        fx = TOOL_CALL_PAYMENT_FIXTURE["invoke"]
        record = build_activity_record(
            agent_id="test-agent",
            action=fx["action"],
            params=fx["params"],
            task=fx["task"],
            context=fx["context"],
        )
        assert record["context"]["user_intent"] == "Transfer $500 to account 12345"
        assert record["context"]["trace_id"] == "tr-cx-001"


# ===========================================================================
# SDK evaluate payload — shadow uses requests.post in bg, enforce uses session
# ===========================================================================

class TestSDKEvaluatePayload:
    """Verify the SDK sends the full context to /evaluate."""

    @pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[f["description"] for f in ALL_FIXTURES])
    def test_shadow_evaluate_receives_context(self, fixture):
        """Shadow mode: requests.post is called in _bg_evaluate. Patch module-level requests.post."""
        fx = fixture["invoke"]
        action = fx["action"] or "manager.route"
        client = _make_client("shadow")

        mock_resp = _mock_shadow_bg_post()

        with patch("agendex.client.requests.post", return_value=mock_resp) as mock_post:
            result, bg_result = _shadow_invoke_and_wait(
                client,
                action=action,
                params=fx["params"],
                task=fx["task"],
                context=fx["context"],
            )

            mock_post.assert_called_once()
            call_kwargs = mock_post.call_args
            payload = call_kwargs.kwargs["json"]

            assert payload["action"] == action
            assert payload["task"] == fx["task"]
            assert payload["mode"] == "shadow"
            # Context should preserve user_intent if provided
            if fx["context"].get("user_intent"):
                assert payload["context"].get("user_intent") == fx["context"]["user_intent"]

    def test_trace_context_merged_into_evaluate(self):
        """Trace IDs passed as kwargs should appear in evaluate payload context."""
        client = _make_client("shadow")

        mock_resp = _mock_shadow_bg_post()

        with patch("agendex.client.requests.post", return_value=mock_resp) as mock_post:
            result, bg_result = _shadow_invoke_and_wait(
                client,
                action="crm.lookup",
                params={"table": "t"},
                task="test",
                context={"user_intent": "lookup"},
                trace_id="tr-42",
                span_id="sp-42",
                parent_span_id="sp-41",
                actor="assistant-a",
            )

            payload = mock_post.call_args.kwargs["json"]
            ctx = payload["context"]
            assert ctx["trace_id"] == "tr-42"
            assert ctx["span_id"] == "sp-42"
            assert ctx["parent_span_id"] == "sp-41"
            assert ctx["actor"] == "assistant-a"


# ===========================================================================
# Shadow vs enforce parity — same payload structure
# ===========================================================================

class TestShadowEnforcePayloadParity:
    """Shadow and enforce must send identical evaluate payloads (modulo 'mode' key)."""

    @pytest.mark.parametrize("fixture", [
        TOOL_CALL_PAYMENT_FIXTURE,
        TOOL_CALL_CRM_LOOKUP_FIXTURE,
        TOOL_CALL_S3_FIXTURE,
    ], ids=lambda f: f["description"])
    def test_evaluate_payload_identical(self, fixture):
        fx = fixture["invoke"]
        action = fx["action"]

        # Shadow — patch requests.post (used in _bg_evaluate)
        shadow_client = _make_client("shadow")
        mock_shadow_resp = _mock_shadow_bg_post()
        with patch("agendex.client.requests.post", return_value=mock_shadow_resp) as mock_shadow_post:
            _shadow_invoke_and_wait(
                shadow_client,
                action=action, params=fx["params"], task=fx["task"], context=fx["context"],
            )
            shadow_payload = mock_shadow_post.call_args.kwargs["json"]

        # Enforce — uses self._session.post
        enforce_client = _make_client("enforce")
        _mock_enforce_session(enforce_client)
        enforce_client.invoke(action=action, params=fx["params"], task=fx["task"], context=fx["context"])
        enforce_eval = enforce_client._session.post.call_args_list[0]
        enforce_payload = enforce_eval.kwargs["json"]

        # Shadow sends "mode" in payload; enforce does not. Compare without it.
        shadow_keys = set(shadow_payload.keys()) - {"mode"}
        enforce_keys = set(enforce_payload.keys()) - {"mode"}
        assert shadow_keys == enforce_keys
        assert shadow_payload["action"] == enforce_payload["action"]
        assert shadow_payload["task"] == enforce_payload["task"]
        assert shadow_payload["params"] == enforce_payload["params"]
        # Context should carry same user_intent
        assert shadow_payload["context"].get("user_intent") == enforce_payload["context"].get("user_intent")


# ===========================================================================
# Event emission — latency
# ===========================================================================

class TestEventLatency:
    """Verify decision latency is captured in event callbacks."""

    def test_shadow_decision_event_has_latency(self):
        """Shadow decision event fires asynchronously from _bg_evaluate."""
        client = _make_client("shadow")
        events = []
        # Thread-safe event collection
        lock = threading.Lock()

        def collect_event(evt):
            with lock:
                events.append(evt)

        mock_resp = _mock_shadow_bg_post(latency_ms=8.5)
        with patch("agendex.client.requests.post", return_value=mock_resp):
            result = client.invoke(
                action="tool.test",
                params={},
                task="latency_test",
                on_event=collect_event,
            )
            # Wait for background to complete
            result["_shadow_future"].result(timeout=5)

        # The tool_call event fires synchronously; decision fires from bg thread
        decision_evts = [e for e in events if e.get("type") == "decision"]
        assert len(decision_evts) == 1, f"Expected 1 decision event, got {len(decision_evts)}"
        decision_evt = decision_evts[0]
        assert decision_evt["policy_latency_ms"] == 8.5
        assert decision_evt["mode"] == "shadow"

    def test_enforce_decision_and_invoke_latency(self):
        client = _make_client("enforce")
        _mock_enforce_session(client)
        events = []
        client.invoke(
            action="tool.test",
            params={},
            task="latency_test",
            on_event=events.append,
        )
        decision_evt = next(e for e in events if e.get("type") == "decision")
        assert decision_evt["policy_latency_ms"] == 12.3
        assert decision_evt["mode"] == "enforce"
        invoke_evt = next(e for e in events if e.get("type") == "invoke_result")
        assert isinstance(invoke_evt["latency_ms"], float)
        assert invoke_evt["latency_ms"] >= 0

    def test_shadow_no_invoke_result_event(self):
        """Shadow mode must NOT emit an invoke_result event."""
        client = _make_client("shadow")
        events = []

        mock_resp = _mock_shadow_bg_post()
        with patch("agendex.client.requests.post", return_value=mock_resp):
            result = client.invoke(action="tool.x", params={}, task="t", on_event=events.append)
            result["_shadow_future"].result(timeout=5)

        assert not any(e.get("type") == "invoke_result" for e in events)


# ===========================================================================
# Coverage flag expectations per fixture
# ===========================================================================

class TestCoverageFlagExpectations:
    """Cross-check expected coverage flags from each fixture against reality."""

    @pytest.mark.parametrize("fixture", [f for f in ALL_FIXTURES if "expected_coverage" in f],
                             ids=[f["description"] for f in ALL_FIXTURES if "expected_coverage" in f])
    def test_expected_coverage_matches_context(self, fixture):
        """
        Simulate what compute_context_coverage would produce from
        the fixture's context (acting as decision_context proxy).
        """
        ctx = fixture["invoke"]["context"]
        fx = fixture["invoke"]

        dc_proxy = {
            "user_intent": ctx.get("user_intent"),
            "assistant_rationale": ctx.get("assistant_rationale") or ctx.get("reasoning"),
            "routing_choice": ctx.get("routing_choice"),
            "tool_action_intent": ctx.get("tool_action_intent") or fx.get("action"),
            "resource_targets": ctx.get("resource_targets") or extract_resources(
                fx["action"] or "manager.route", fx["params"]
            ),
        }

        computed = {
            "has_user_intent": bool(dc_proxy["user_intent"]),
            "has_assistant_rationale": bool(dc_proxy["assistant_rationale"]),
            "has_routing_choice": bool(dc_proxy["routing_choice"]),
            "has_tool_action_intent": bool(dc_proxy["tool_action_intent"]),
            "has_resource_targets": bool(dc_proxy["resource_targets"]),
        }

        expected = fixture["expected_coverage"]
        for key in CORE_COVERAGE_KEYS:
            assert computed[key] == expected[key], (
                f"{fixture['description']}: {key} expected={expected[key]}, got={computed[key]}"
            )


# ===========================================================================
# Shadow mode transport resilience
# ===========================================================================

class TestShadowTransportResilience:
    """Shadow mode must never block; transport failures are non-fatal."""

    def test_shadow_returns_pending_immediately(self):
        """invoke() returns immediately with decision=pending even before bg completes."""
        client = _make_client("shadow")

        # Make bg_evaluate hang so we can test immediate return
        mock_resp = _mock_shadow_bg_post()
        with patch("agendex.client.requests.post", return_value=mock_resp):
            result = client.invoke(action="tool.x", params={}, task="t")

        assert result["_agendex"] == "shadow"
        assert result["decision"] == "pending"
        assert result["execute"] is True
        assert "_shadow_future" in result
        # Clean up
        result["_shadow_future"].result(timeout=5)

    def test_evaluate_timeout_bg_result_is_unknown(self):
        """When bg evaluate times out, future.result() returns decision=unknown."""
        client = _make_client("shadow")

        with patch("agendex.client.requests.post", side_effect=requests_lib.exceptions.Timeout("timed out")):
            result = client.invoke(action="tool.x", params={}, task="t")
            assert result["decision"] == "pending"
            assert result["execute"] is True
            # Wait for background to complete
            bg_result = result["_shadow_future"].result(timeout=5)
            assert bg_result["decision"] == "unknown"
            assert bg_result["transport_status"]["transport_ok"] is False
            assert bg_result["transport_status"]["used_fallback"] is True

    def test_evaluate_connection_error_bg_result_is_unknown(self):
        """When bg evaluate has a connection error, future resolves with decision=unknown."""
        client = _make_client("shadow")

        with patch("agendex.client.requests.post",
                    side_effect=requests_lib.exceptions.ConnectionError("connection refused")):
            result = client.invoke(action="tool.x", params={}, task="t")
            assert result["decision"] == "pending"
            bg_result = result["_shadow_future"].result(timeout=5)
            assert bg_result["decision"] == "unknown"
            assert "connection refused" in bg_result["transport_status"]["transport_error"]

    def test_shadow_success_bg_result_has_outcome(self):
        """When bg evaluate succeeds, future.result() has the real decision."""
        client = _make_client("shadow")

        mock_resp = _mock_shadow_bg_post(outcome="would_allow", latency_ms=5.0)
        with patch("agendex.client.requests.post", return_value=mock_resp):
            result = client.invoke(action="tool.x", params={}, task="t")
            bg_result = result["_shadow_future"].result(timeout=5)
            assert bg_result["decision"] == "would_allow"
            assert bg_result["server_latency_ms"] == 5.0
            assert bg_result["transport_status"]["transport_ok"] is True


# ===========================================================================
# DecisionContextBuilder — contract field completeness
# ===========================================================================

class TestDecisionContextBuilderContract:
    """DecisionContextBuilder.build() must produce all frozen contract fields."""

    def test_all_decision_context_keys_present(self):
        builder = DecisionContextBuilder()
        capture = builder.build(
            decision_type="tool_call",
            action="crm.lookup",
            task="support",
            context={"user_intent": "find customer", "reasoning": "looking up"},
            params={"table": "customers"},
            trace_id="tr-1",
            span_id="sp-1",
            parent_span_id="sp-0",
            actor="crm_assistant",
            agent_id="agent-1",
            tenant_id="default",
            mode="shadow",
        )
        dc = capture.decision_context
        for key in DECISION_CONTEXT_REQUIRED_KEYS:
            assert key in dc, f"Missing key in decision_context: {key}"

    def test_all_supporting_context_keys_present(self):
        builder = DecisionContextBuilder()
        capture = builder.build(
            decision_type="tool_call",
            action="s3.read",
            task="audit",
            params={"bucket": "docs", "key": "report.pdf"},
            context={"user_intent": "review", "reasoning": "fetching"},
        )
        sc = capture.supporting_context
        for key in SUPPORTING_CONTEXT_EXPECTED_KEYS:
            assert key in sc, f"Missing key in supporting_context: {key}"

    def test_coverage_flags_from_capture(self):
        builder = DecisionContextBuilder()
        capture = builder.build(
            decision_type="tool_call",
            action="payments.ach",
            task="pay",
            context={
                "user_intent": "transfer",
                "reasoning": "processing",
                "routing_choice": "payments_agent",
            },
            params={"kwargs": {"amount": 500}},
        )
        cc = capture.context_coverage
        assert cc["has_user_intent"] is True
        assert cc["has_assistant_rationale"] is True
        assert cc["has_routing_choice"] is True
        assert cc["has_tool_action_intent"] is True

    def test_manager_route_no_tool_action_intent(self):
        builder = DecisionContextBuilder()
        capture = builder.build(
            decision_type="manager_route",
            action="manager.route",
            task="support",
            context={
                "user_intent": "help",
                "reasoning": "routing",
                "routing_choice": "agent_a",
            },
            params={},
        )
        dc = capture.decision_context
        # manager_route: tool_action_intent should be None
        assert dc["tool_action_intent"] is None
        assert capture.context_coverage["has_tool_action_intent"] is False


# ===========================================================================
# DecisionCapture.as_event_fields — event enrichment
# ===========================================================================

class TestDecisionCaptureEventFields:
    """as_event_fields() must include structured payloads for event persistence."""

    def test_event_fields_include_all_structured_payloads(self):
        builder = DecisionContextBuilder()
        capture = builder.build(
            decision_type="tool_call",
            action="crm.lookup",
            task="support",
            context={"user_intent": "find"},
            params={"table": "t"},
        )
        fields = capture.as_event_fields()
        assert "decision_context" in fields
        assert "supporting_context" in fields
        assert "context_coverage" in fields
        assert "transport_status" in fields
        assert "resources" in fields
