#!/usr/bin/env python3
"""
Tests for Story 3.03: TelnyxWebhookInterceptor — call.answered Handler
AIVA RLM Nexus PRD v2 — Track A

Black box tests (BB1-BB5): verify the public contract from the outside.
White box tests (WB1-WB5): verify internal paths, Redis value shape,
                            logging invariants, and _extract_field behaviour.

ALL external dependencies (Redis, filesystem) are fully mocked so the suite
runs without any live infrastructure.
"""
import asyncio
import json
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.interceptors.telnyx_webhook_interceptor import (
    EVENTS_LOG_PATH,
    TelnyxWebhookInterceptor,
)

# ---------------------------------------------------------------------------
# Shared fixtures / helpers
# ---------------------------------------------------------------------------

_VALID_SESSION_ID = "sess-answered-abc-123"
_VALID_CONTROL_ID = "ctrl-xyz-456"

_VALID_PAYLOAD = {
    "data": {
        "payload": {
            "call_session_id": _VALID_SESSION_ID,
            "call_control_id": _VALID_CONTROL_ID,
            "direction": "inbound",
        }
    }
}

_PAYLOAD_NO_CONTROL_ID = {
    "data": {
        "payload": {
            "call_session_id": _VALID_SESSION_ID,
            # call_control_id deliberately absent
        }
    }
}


def _make_redis_mock() -> MagicMock:
    """Return a minimal Redis-client mock with a .set() method."""
    redis = MagicMock()
    redis.set = MagicMock(return_value=True)
    return redis


def _make_interceptor(redis=None) -> TelnyxWebhookInterceptor:
    """Return a fresh interceptor with optional mocked deps (no DB needed here)."""
    return TelnyxWebhookInterceptor(db_conn=None, redis_client=redis)


def _run(coro):
    """Run a coroutine synchronously (works on Python 3.7+)."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ---------------------------------------------------------------------------
# Black-box tests (BB) — treat the class as a black box
# ---------------------------------------------------------------------------


class TestBB1_ValidPayloadReturnsOk:
    """BB1: Valid call.answered payload → {"status": "ok", "capture_started": True}."""

    def test_returns_status_ok(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["status"] == "ok"

    def test_returns_capture_started_true(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["capture_started"] is True

    def test_returns_correct_session_id(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["session_id"] == _VALID_SESSION_ID

    def test_return_type_is_dict(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert isinstance(result, dict)


class TestBB2_RedisKeySetAfterHandler:
    """BB2: Redis key aiva:state:{session_id} is set after the handler runs."""

    def test_redis_set_called(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        redis.set.assert_called_once()

    def test_redis_key_is_correct(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        call_args = redis.set.call_args
        key = call_args[0][0]
        assert key == f"aiva:state:{_VALID_SESSION_ID}"

    def test_redis_value_is_json_string(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        call_args = redis.set.call_args
        raw_value = call_args[0][1]
        # Must be valid JSON
        parsed = json.loads(raw_value)
        assert isinstance(parsed, dict)


class TestBB3_RedisWriteFailsStillReturnsOk:
    """BB3: Redis write fails → still returns {"status": "ok"} (non-fatal)."""

    def test_redis_exception_does_not_propagate(self):
        redis = _make_redis_mock()
        redis.set.side_effect = Exception("Redis connection refused")
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["status"] == "ok"

    def test_redis_exception_capture_started_still_true(self):
        redis = _make_redis_mock()
        redis.set.side_effect = Exception("timeout")
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["capture_started"] is True

    def test_redis_failure_logs_redis_error_event(self):
        redis = _make_redis_mock()
        redis.set.side_effect = Exception("ECONNREFUSED")
        interceptor = _make_interceptor(redis=redis)

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log
        _run(interceptor.handle_call_answered(_VALID_PAYLOAD))

        error_events = [e for e in logged if e[0] == "redis_error"]
        assert len(error_events) == 1
        assert "error" in error_events[0][1]


class TestBB4_NoRedisClientNoCrash:
    """BB4: No Redis client (None) → no crash, returns ok."""

    def test_none_redis_returns_ok(self):
        interceptor = _make_interceptor(redis=None)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["status"] == "ok"

    def test_none_redis_capture_started_true(self):
        interceptor = _make_interceptor(redis=None)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        assert result["capture_started"] is True

    def test_none_redis_does_not_raise(self):
        interceptor = _make_interceptor(redis=None)
        with patch.object(interceptor, "_log_event"):
            try:
                _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
            except Exception as exc:
                pytest.fail(f"Unexpected exception with redis=None: {exc}")


class TestBB5_MissingCallControlId:
    """BB5: Missing call_control_id → handler still works correctly."""

    def test_missing_control_id_returns_ok(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_PAYLOAD_NO_CONTROL_ID))
        assert result["status"] == "ok"

    def test_missing_control_id_capture_started_true(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_PAYLOAD_NO_CONTROL_ID))
        assert result["capture_started"] is True

    def test_missing_control_id_correct_session_id(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_answered(_PAYLOAD_NO_CONTROL_ID))
        assert result["session_id"] == _VALID_SESSION_ID


# ---------------------------------------------------------------------------
# White-box tests (WB) — test internals, Redis value shape, logging, _extract_field
# ---------------------------------------------------------------------------


class TestWB1_CallControlIdExtraction:
    """WB1: call_control_id extracted from payload["data"]["payload"]["call_control_id"]."""

    def test_extract_field_returns_correct_value(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field(_VALID_PAYLOAD, "call_control_id")
        assert result == _VALID_CONTROL_ID

    def test_extract_field_returns_session_id(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field(_VALID_PAYLOAD, "call_session_id")
        assert result == _VALID_SESSION_ID

    def test_redis_set_uses_session_id_from_payload(self):
        """Confirm the Redis key uses the session_id extracted from the payload."""
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        call_args = redis.set.call_args
        key = call_args[0][0]
        assert _VALID_SESSION_ID in key


class TestWB2_RedisValueContainsActiveStatus:
    """WB2: Redis value JSON contains {"status": "active"}."""

    def test_redis_value_has_status_active(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert parsed["status"] == "active"

    def test_redis_value_status_is_string(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert isinstance(parsed["status"], str)


class TestWB3_RedisValueContainsAnsweredAt:
    """WB3: Redis value JSON contains "answered_at" field as UTC ISO-8601 timestamp."""

    def test_redis_value_has_answered_at_key(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert "answered_at" in parsed

    def test_redis_answered_at_is_parseable_as_utc_datetime(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        dt = datetime.fromisoformat(parsed["answered_at"])
        assert dt.tzinfo is not None

    def test_redis_answered_at_is_string(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_answered(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert isinstance(parsed["answered_at"], str)


class TestWB4_EventLoggedToEventsJsonl:
    """WB4: Event with type call_answered is logged to events.jsonl."""

    def test_log_event_called_with_call_answered(self):
        interceptor = _make_interceptor()
        logged = []

        def capture(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture
        _run(interceptor.handle_call_answered(_VALID_PAYLOAD))

        answered_events = [e for e in logged if e[0] == "call_answered"]
        assert len(answered_events) == 1

    def test_logged_event_contains_session_id(self):
        interceptor = _make_interceptor()
        logged = []

        def capture(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture
        _run(interceptor.handle_call_answered(_VALID_PAYLOAD))

        answered_events = [e for e in logged if e[0] == "call_answered"]
        assert answered_events[0][1]["session_id"] == _VALID_SESSION_ID

    def test_logged_event_contains_call_control_id(self):
        interceptor = _make_interceptor()
        logged = []

        def capture(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture
        _run(interceptor.handle_call_answered(_VALID_PAYLOAD))

        answered_events = [e for e in logged if e[0] == "call_answered"]
        assert answered_events[0][1]["call_control_id"] == _VALID_CONTROL_ID

    def test_log_event_writes_telnyx_prefixed_event_type_to_file(self):
        """Verify the written JSON has the telnyx_call_answered event_type prefix."""
        interceptor = _make_interceptor()

        written_content = []

        def capture_write(path, mode="r", **kwargs):
            if mode == "a":
                m = MagicMock()
                m.__enter__ = lambda s: s
                m.__exit__ = MagicMock(return_value=False)
                m.write = lambda data: written_content.append(data)
                return m
            return open(path, mode, **kwargs)

        with patch("builtins.open", side_effect=capture_write):
            with patch("pathlib.Path.mkdir"):
                interceptor._log_event("call_answered", {"session_id": "test-303"})

        assert len(written_content) == 1
        record = json.loads(written_content[0].strip())
        assert record["event_type"] == "telnyx_call_answered"


class TestWB5_ExtractFieldReturnsNoneOnMissingKeys:
    """WB5: _extract_field returns None when the nested key path is missing."""

    def test_returns_none_for_missing_field(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field(_VALID_PAYLOAD, "nonexistent_field")
        assert result is None

    def test_returns_none_for_empty_payload(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field({}, "call_control_id")
        assert result is None

    def test_returns_none_when_data_key_missing(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field({"other": "key"}, "call_control_id")
        assert result is None

    def test_returns_none_when_payload_key_missing(self):
        interceptor = _make_interceptor()
        payload = {"data": {"not_payload": {}}}
        result = interceptor._extract_field(payload, "call_control_id")
        assert result is None

    def test_returns_none_when_payload_is_none_value(self):
        interceptor = _make_interceptor()
        payload = {"data": {"payload": None}}
        result = interceptor._extract_field(payload, "call_control_id")
        assert result is None

    def test_returns_none_for_none_field_value(self):
        """A None value in the payload for the field itself returns None."""
        interceptor = _make_interceptor()
        payload = {"data": {"payload": {"call_control_id": None}}}
        result = interceptor._extract_field(payload, "call_control_id")
        assert result is None

    def test_returns_value_when_field_present(self):
        interceptor = _make_interceptor()
        result = interceptor._extract_field(_VALID_PAYLOAD, "call_control_id")
        assert result == _VALID_CONTROL_ID


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
