"""Tests for AgendexClient."""
import json
import pytest
from unittest.mock import Mock, patch, MagicMock
import os
import requests

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

from agendex import AgendexClient
from agendex.errors import AgendexError, DeniedError, PendingApprovalError, ConfigurationError


class TestAgendexClient:
    """Test AgendexClient functionality."""
    
    def test_init_from_params(self):
        """Test initialization with explicit parameters."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='shadow'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )
        
        assert client.base_url == "http://test:8000"
        assert client.agent_id == "test_agent"
        assert client.mode == "shadow"

    def test_headers_include_tenant_id_when_set(self):
        """Tenant header should be sent when tenant_id is configured."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='shadow'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
                tenant_id="tenant-a",
            )

        headers = client._headers()
        assert headers["X-Agendex-Tenant-Id"] == "tenant-a"
    
    def test_init_missing_agent_id_raises(self):
        """Test that missing agent_id raises ConfigurationError."""
        with pytest.raises(ConfigurationError) as exc_info:
            AgendexClient(
                base_url="http://test:8000",
                agent_id=None,
                token="test_token",
            )
        assert "agent_id is required" in str(exc_info.value)
    
    def test_init_missing_token_raises(self):
        """Test that missing token raises ConfigurationError."""
        # Ensure no environment token is accidentally picked up.
        with patch.dict(os.environ, {}, clear=True):
            with pytest.raises(ConfigurationError) as exc_info:
                AgendexClient(
                    base_url="http://test:8000",
                    agent_id="test_agent",
                    token=None,
                )
        assert "token is required" in str(exc_info.value)
    
    def test_invoke_shadow_mode(self):
        """Test invoke in shadow mode."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='shadow'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )
        
        # Mock the session
        client._session = Mock()
        mock_activity = Mock(ok=True)
        mock_eval = Mock(ok=True)
        mock_eval.json = Mock(return_value={
            "decision": {"outcome": "would_allow", "reason": "Matched rule"},
            "request_id": "req-shadow",
        })
        client._session.post = Mock(side_effect=[mock_activity, mock_eval])
        
        result = client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
        )

        # In shadow mode we do not invoke remotely; caller executes tool locally.
        assert result["_agendex"] == "shadow"
        assert result["decision"] == "would_allow"
        assert result["execute"] is True
        assert client._session.post.call_count == 2
        urls = [call.args[0] for call in client._session.post.call_args_list]
        assert urls[0].endswith("/activity")
        assert urls[1].endswith("/evaluate")

    def test_invoke_shadow_mode_emits_latency_events(self):
        """Shadow mode should emit decision latency and no invoke result event."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='shadow'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        events = []
        client._session = Mock()
        mock_activity = Mock(ok=True)
        mock_eval = Mock(ok=True)
        mock_eval.json = Mock(return_value={
            "decision": {"outcome": "would_allow", "reason": "Matched rule"},
            "request_id": "req-shadow",
            "latency_ms": 11.5,
        })
        client._session.post = Mock(side_effect=[mock_activity, mock_eval])

        client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
            on_event=events.append,
        )

        decision_event = next(evt for evt in events if evt.get("type") == "decision")
        assert decision_event["latency_ms"] == 11.5
        assert not any(evt.get("type") == "invoke_result" for evt in events)

    def test_invoke_shadow_mode_evaluate_failure_returns_shadow_envelope(self):
        """Shadow mode should not block execution when evaluate fails."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='shadow'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        client._session = Mock()
        mock_activity = Mock(ok=True)
        client._session.post = Mock(side_effect=[mock_activity, requests.exceptions.Timeout("timed out")])

        result = client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
        )

        assert result["_agendex"] == "shadow"
        assert result["decision"] == "unknown"
        assert result["execute"] is True
    
    def test_invoke_enforce_mode_allow(self):
        """Test invoke in enforce mode with allow decision."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='enforce'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )
        
        # Mock the session for evaluate and invoke
        mock_response = Mock()
        mock_response.ok = True
        mock_response.status_code = 200
        mock_response.json = Mock(return_value={
            "decision": {"outcome": "allow", "reason": "Matched rule"},
            "token": "scoped-token-123",
            "request_id": "req-123",
        })
        mock_response.raise_for_status = Mock()
        
        mock_invoke_response = Mock()
        mock_invoke_response.ok = True
        mock_invoke_response.status_code = 200
        mock_invoke_response.json = Mock(return_value={
            "success": True,
            "result": {"data": "test_result"},
        })
        mock_invoke_response.raise_for_status = Mock()
        
        client._session = Mock()
        client._session.post = Mock(side_effect=[mock_response, mock_invoke_response])
        
        result = client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
        )
        
        assert result == {"data": "test_result"}

    def test_invoke_enforce_mode_emits_invoke_latency(self):
        """Enforce mode should emit invoke latency in invoke_result callback events."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='enforce'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        events = []
        mock_eval = Mock()
        mock_eval.ok = True
        mock_eval.status_code = 200
        mock_eval.json = Mock(return_value={
            "decision": {"outcome": "allow", "reason": "Matched rule"},
            "token": "scoped-token-123",
            "request_id": "req-123",
            "latency_ms": 9.2,
        })
        mock_eval.raise_for_status = Mock()

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

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

        client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
            on_event=events.append,
        )

        decision_event = next(evt for evt in events if evt.get("type") == "decision")
        assert decision_event["latency_ms"] == 9.2
        invoke_event = next(evt for evt in events if evt.get("type") == "invoke_result")
        assert isinstance(invoke_event.get("latency_ms"), float)

    def test_invoke_enforce_includes_trace_context(self):
        """Trace context should be merged into evaluate payload context."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='enforce'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        mock_eval = Mock()
        mock_eval.ok = True
        mock_eval.status_code = 200
        mock_eval.json = Mock(return_value={
            "decision": {"outcome": "allow", "reason": "ok"},
            "token": "scoped-token-123",
            "request_id": "req-123",
        })
        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 = Mock()
        client._session.post = Mock(side_effect=[mock_eval, mock_invoke])

        client.invoke(
            action="tool.test",
            params={"key": "value"},
            task="test_task",
            context={"reasoning": "need info"},
            trace_id="trace-1",
            span_id="span-1",
            parent_span_id="span-0",
            actor="assistant-analyst",
        )

        evaluate_call = client._session.post.call_args_list[0]
        payload = evaluate_call.kwargs["json"]
        assert payload["context"]["trace_id"] == "trace-1"
        assert payload["context"]["span_id"] == "span-1"
        assert payload["context"]["parent_span_id"] == "span-0"
        assert payload["context"]["actor"] == "assistant-analyst"
        assert payload["context"]["reasoning"] == "need info"
    
    def test_invoke_enforce_mode_deny(self):
        """Test invoke in enforce mode with deny decision."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='enforce'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )
        
        mock_response = Mock()
        mock_response.ok = True
        mock_response.status_code = 200
        mock_response.json = Mock(return_value={
            "decision": {
                "outcome": "deny",
                "reason": "Action not allowed",
                "matched_rule": "deny_all",
            },
            "request_id": "req-123",
        })
        mock_response.raise_for_status = Mock()
        
        client._session = Mock()
        client._session.post = Mock(return_value=mock_response)
        
        with pytest.raises(DeniedError) as exc_info:
            client.invoke(
                action="tool.dangerous",
                params={},
                task="test_task",
            )
        
        assert exc_info.value.action == "tool.dangerous"
        assert "not allowed" in exc_info.value.reason
    
    def test_invoke_enforce_mode_pending_approval(self):
        """Test invoke in enforce mode with pending approval."""
        with patch.object(AgendexClient, '_fetch_mode', return_value='enforce'):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )
        
        mock_response = Mock()
        mock_response.ok = True
        mock_response.status_code = 200
        mock_response.json = Mock(return_value={
            "decision": {
                "outcome": "pending_approval",
                "reason": "Requires approval",
            },
            "approval_id": "apr-0001",
            "request_id": "req-123",
        })
        mock_response.raise_for_status = Mock()
        
        client._session = Mock()
        client._session.post = Mock(return_value=mock_response)
        
        with pytest.raises(PendingApprovalError) as exc_info:
            client.invoke(
                action="tool.payment",
                params={"amount": 5000},
                task="test_task",
            )
        
        assert exc_info.value.approval_id == "apr-0001"

    def test_ingest_events_success(self):
        """Event ingestion posts to /events/ingest and returns server payload."""
        with patch.object(AgendexClient, "_fetch_mode", return_value="shadow"):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        mock_response = Mock()
        mock_response.raise_for_status = Mock()
        mock_response.json = Mock(return_value={"ingested": 2})

        client._session = Mock()
        client._session.post = Mock(return_value=mock_response)

        events = [{"event_type": "a"}, {"event_type": "b"}]
        result = client.ingest_events(events)

        assert result == {"ingested": 2}
        call = client._session.post.call_args
        assert call.kwargs["json"] == {"events": events}
        assert call.kwargs["headers"]["Authorization"] == "Bearer test_token"

    def test_ingest_events_empty_batch(self):
        """Empty or invalid event batches should no-op without network calls."""
        with patch.object(AgendexClient, "_fetch_mode", return_value="shadow"):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        client._session = Mock()
        result = client.ingest_events([])
        assert result == {"ingested": 0}
        client._session.post.assert_not_called()

    def test_ingest_events_request_error(self):
        """Transport errors during ingestion are surfaced as AgendexError."""
        with patch.object(AgendexClient, "_fetch_mode", return_value="shadow"):
            client = AgendexClient(
                base_url="http://test:8000",
                agent_id="test_agent",
                token="test_token",
            )

        client._session = Mock()
        client._session.post.side_effect = requests.exceptions.RequestException("boom")

        with pytest.raises(AgendexError):
            client.ingest_events([{"event_type": "tool_call"}])


class TestActivityExtraction:
    """Test automatic resource extraction."""
    
    def test_extract_http_resource(self):
        """Test HTTP resource extraction from params."""
        from agendex.activity import extract_resources
        
        resources = extract_resources("http.request", {
            "url": "https://api.example.com/v1/users",
            "method": "POST",
        })
        
        assert len(resources) == 1
        assert resources[0]["type"] == "http"
        assert resources[0]["domain"] == "api.example.com"
        assert resources[0]["method"] == "POST"
    
    def test_extract_s3_resource(self):
        """Test S3 resource extraction from params."""
        from agendex.activity import extract_resources
        
        resources = extract_resources("s3.read", {
            "bucket": "my-bucket",
            "key": "reports/monthly/2025-01.csv",
        })
        
        assert len(resources) == 1
        assert resources[0]["type"] == "s3"
        assert resources[0]["bucket"] == "my-bucket"
        assert resources[0]["key_prefix"] == "reports/monthly/"
    
    def test_extract_payment_resource(self):
        """Test payment resource extraction from tool kwargs."""
        from agendex.activity import extract_resources
        
        resources = extract_resources("tool.payment", {
            "kwargs": {"amount": 1500, "currency": "EUR"},
        })
        
        assert any(r["type"] == "payment" for r in resources)
        payment = next(r for r in resources if r["type"] == "payment")
        assert payment["amount"] == 1500
        assert payment["currency"] == "EUR"
