#!/usr/bin/env python3
"""
Tests for Story 3.04: TelnyxWebhookInterceptor — call.hangup Handler
AIVA RLM Nexus PRD v2 — Track A

Black box tests (BB1-BB3): verify the public contract from the outside.
White box tests (WB1-WB3): verify internal paths — enricher failure, timeout,
                            session_id→conversation_id Postgres mapping.

ALL external dependencies (Redis, Postgres, PostCallEnricher, filesystem) are
fully mocked so the suite runs without any live infrastructure.
"""
import asyncio
import json
import sys
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.interceptors.telnyx_webhook_interceptor import (
    EVENTS_LOG_PATH,
    PostCallEnricher,
    TelnyxWebhookInterceptor,
)

# ---------------------------------------------------------------------------
# Shared fixtures / helpers
# ---------------------------------------------------------------------------

_VALID_SESSION_ID = "sess-hangup-abc-789"

_VALID_PAYLOAD = {
    "data": {
        "payload": {
            "call_session_id": _VALID_SESSION_ID,
            "direction": "inbound",
        }
    }
}

_EMPTY_PAYLOAD = {}


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_db_mock() -> MagicMock:
    """Return a minimal psycopg2-connection mock."""
    cursor_mock = MagicMock()
    cursor_mock.__enter__ = MagicMock(return_value=cursor_mock)
    cursor_mock.__exit__ = MagicMock(return_value=False)

    db = MagicMock()
    db.cursor.return_value = cursor_mock
    return db


def _make_interceptor(db=None, redis=None) -> TelnyxWebhookInterceptor:
    """Return a fresh interceptor with optional mocked deps."""
    return TelnyxWebhookInterceptor(db_conn=db, 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_ValidHangupReturnsOk:
    """BB1: Valid call.hangup payload → {"status": "ok", "enrichment_started": True} returned."""

    def test_returns_status_ok(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert result["status"] == "ok"

    def test_returns_enrichment_started_true(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert result["enrichment_started"] is True

    def test_return_type_is_dict(self):
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert isinstance(result, dict)

    def test_return_has_exactly_two_keys(self):
        """Response must contain exactly {"status": ..., "enrichment_started": ...}."""
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert set(result.keys()) == {"status", "enrichment_started"}


class TestBB2_PostgresEndedAtUpdated:
    """BB2: Postgres royal_conversations.ended_at updated after handler."""

    def test_db_cursor_called_on_hangup(self):
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        db.cursor.assert_called()

    def test_cursor_execute_called_with_update(self):
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        db.cursor.return_value.execute.assert_called()

    def test_sql_is_update_not_insert(self):
        """The Postgres query must be an UPDATE (set ended_at), not an INSERT."""
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        execute_call = db.cursor.return_value.execute.call_args
        sql = execute_call[0][0].strip().upper()
        assert sql.startswith("UPDATE")

    def test_sql_targets_royal_conversations(self):
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        execute_call = db.cursor.return_value.execute.call_args
        sql = execute_call[0][0].upper()
        assert "ROYAL_CONVERSATIONS" in sql

    def test_db_commit_called(self):
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        db.commit.assert_called()


class TestBB3_RedisStateShowsEnded:
    """BB3: Redis state shows {"status": "ended", "ended_at": ...} after handler."""

    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_hangup(_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_hangup(_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_status_is_ended(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert parsed["status"] == "ended"

    def test_redis_value_has_ended_at_key(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        assert "ended_at" in parsed

    def test_redis_ended_at_is_parseable_utc_datetime(self):
        redis = _make_redis_mock()
        interceptor = _make_interceptor(redis=redis)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        raw_value = redis.set.call_args[0][1]
        parsed = json.loads(raw_value)
        dt = datetime.fromisoformat(parsed["ended_at"])
        assert dt.tzinfo is not None


# ---------------------------------------------------------------------------
# White-box tests (WB) — test internal failure paths and session_id routing
# ---------------------------------------------------------------------------


class TestWB1_EnricherFailsWithinDeadline:
    """WB1: PostCallEnricher raises within 3s → logged as error, no crash, returns ok."""

    def test_enricher_exception_logged_not_raised(self):
        interceptor = _make_interceptor()

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        async def failing_enrich(session_id):
            raise RuntimeError("enricher exploded")

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ) as MockEnricher:
            instance = MockEnricher.return_value
            instance.enrich = failing_enrich
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        assert result["status"] == "ok"
        error_events = [e for e in logged if e[0] == "enricher_error"]
        assert len(error_events) == 1
        assert "enricher exploded" in error_events[0][1]["error"]

    def test_enricher_exception_still_returns_enrichment_started_true(self):
        interceptor = _make_interceptor()

        with patch.object(interceptor, "_log_event"):

            async def failing_enrich(session_id):
                raise ValueError("bad data")

            with patch(
                "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
            ) as MockEnricher:
                instance = MockEnricher.return_value
                instance.enrich = failing_enrich
                result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        assert result["enrichment_started"] is True

    def test_enricher_error_event_contains_session_id(self):
        interceptor = _make_interceptor()

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        async def failing_enrich(session_id):
            raise RuntimeError("oops")

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ) as MockEnricher:
            instance = MockEnricher.return_value
            instance.enrich = failing_enrich
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        error_events = [e for e in logged if e[0] == "enricher_error"]
        assert error_events[0][1]["session_id"] == _VALID_SESSION_ID


class TestWB2_EnricherTimesOut:
    """WB2: PostCallEnricher times out at 3s → TimeoutError logged, no crash, returns ok."""

    def test_timeout_does_not_propagate(self):
        interceptor = _make_interceptor()

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        async def slow_enrich(session_id):
            # Sleep far beyond the 3s deadline — asyncio.wait_for will cancel this
            await asyncio.sleep(10)

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ) as MockEnricher:
            instance = MockEnricher.return_value
            instance.enrich = slow_enrich

            # Use a very short timeout so the test finishes quickly
            with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
                result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        assert result["status"] == "ok"

    def test_timeout_logged_as_enricher_timeout_event(self):
        interceptor = _make_interceptor()

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ):
            with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
                _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        timeout_events = [e for e in logged if e[0] == "enricher_timeout"]
        assert len(timeout_events) == 1

    def test_timeout_event_contains_session_id(self):
        interceptor = _make_interceptor()

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ):
            with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
                _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        timeout_events = [e for e in logged if e[0] == "enricher_timeout"]
        assert timeout_events[0][1]["session_id"] == _VALID_SESSION_ID

    def test_timeout_still_returns_enrichment_started_true(self):
        interceptor = _make_interceptor()

        with patch.object(interceptor, "_log_event"):
            with patch(
                "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
            ):
                with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
                    result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        assert result["enrichment_started"] is True


class TestWB3_SessionIdMappedToConversationId:
    """WB3: session_id from Telnyx payload correctly maps to conversation_id in Postgres UPDATE."""

    def test_update_params_include_session_id(self):
        """The UPDATE must pass session_id as the WHERE conversation_id value."""
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        execute_call = db.cursor.return_value.execute.call_args
        params = execute_call[0][1]  # second positional arg = tuple of params
        assert _VALID_SESSION_ID in params

    def test_update_params_include_datetime_for_ended_at(self):
        """The UPDATE must pass a datetime object as the ended_at value."""
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        execute_call = db.cursor.return_value.execute.call_args
        params = execute_call[0][1]
        assert any(isinstance(p, datetime) for p in params)

    def test_update_ended_at_is_utc(self):
        """ended_at datetime passed to Postgres must be UTC-aware."""
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        execute_call = db.cursor.return_value.execute.call_args
        params = execute_call[0][1]
        dt_param = next((p for p in params if isinstance(p, datetime)), None)
        assert dt_param is not None
        assert dt_param.tzinfo == timezone.utc

    def test_redis_failure_does_not_prevent_postgres_update(self):
        """Redis failure must not prevent the Postgres ended_at update."""
        redis = _make_redis_mock()
        redis.set.side_effect = Exception("Redis ECONNREFUSED")
        db = _make_db_mock()
        interceptor = _make_interceptor(db=db, redis=redis)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        # Postgres update must still have been attempted
        db.cursor.assert_called()
        assert result["status"] == "ok"

    def test_postgres_failure_does_not_crash_response(self):
        """Postgres failure must not prevent the response being returned."""
        db = _make_db_mock()
        db.cursor.return_value.execute.side_effect = Exception("PG: relation not found")
        interceptor = _make_interceptor(db=db)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert result["status"] == "ok"
        assert result["enrichment_started"] is True

    def test_call_hangup_event_logged_to_events_jsonl(self):
        """The call_hangup event must be logged regardless of other failures."""
        interceptor = _make_interceptor()
        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log
        _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        hangup_events = [e for e in logged if e[0] == "call_hangup"]
        assert len(hangup_events) == 1
        assert hangup_events[0][1]["session_id"] == _VALID_SESSION_ID


# ---------------------------------------------------------------------------
# Additional resilience tests
# ---------------------------------------------------------------------------


class TestResilience:
    """Extra resilience checks — all non-fatal failures still return ok."""

    def test_empty_payload_still_returns_ok(self):
        """Missing session_id falls back to UUID4 — no crash."""
        interceptor = _make_interceptor()
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_EMPTY_PAYLOAD))
        assert result["status"] == "ok"
        assert result["enrichment_started"] is True

    def test_no_redis_no_db_returns_ok(self):
        """No infra at all — still returns ok."""
        interceptor = _make_interceptor(db=None, redis=None)
        with patch.object(interceptor, "_log_event"):
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))
        assert result["status"] == "ok"

    def test_all_failures_simultaneously_still_returns_ok(self):
        """Redis fails + Postgres fails + enricher fails — response is always ok."""
        redis = _make_redis_mock()
        redis.set.side_effect = Exception("Redis down")
        db = _make_db_mock()
        db.cursor.return_value.execute.side_effect = Exception("PG down")
        interceptor = _make_interceptor(db=db, redis=redis)

        logged = []

        def capture_log(event_type, data):
            logged.append((event_type, data))

        interceptor._log_event = capture_log

        async def boom(session_id):
            raise RuntimeError("enricher also down")

        with patch(
            "core.interceptors.telnyx_webhook_interceptor.PostCallEnricher"
        ) as MockEnricher:
            instance = MockEnricher.return_value
            instance.enrich = boom
            result = _run(interceptor.handle_call_hangup(_VALID_PAYLOAD))

        assert result["status"] == "ok"
        assert result["enrichment_started"] is True


class TestPostCallEnricherStub:
    """Verify the PostCallEnricher stub is importable and runs without error."""

    def test_enricher_is_instantiable(self):
        enricher = PostCallEnricher()
        assert enricher is not None

    def test_enrich_returns_none(self):
        enricher = PostCallEnricher()
        result = _run(enricher.enrich("test-session-id"))
        assert result is None

    def test_enrich_does_not_raise(self):
        enricher = PostCallEnricher()
        try:
            _run(enricher.enrich("any-session"))
        except Exception as exc:
            pytest.fail(f"PostCallEnricher.enrich raised unexpectedly: {exc}")


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
