#!/usr/bin/env python3
"""
Tests for Story 3.06: RLMCaptureAgent — Transcript Fetch + Speaker Labeling
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, error handling, and counter.

ALL external dependencies (httpx, filesystem, os.environ) are fully mocked
so the suite runs without any live Telnyx API calls.
"""
import asyncio
import sys
import time
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.agents.rlm_capture_agent import RLMCaptureAgent

# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

_SESSION_ID = "sess-3-06-test"
_CALL_CTRL_ID = "cc-def-uvw-002"


def _make_agent(call_direction: str = "inbound") -> RLMCaptureAgent:
    """Return a fresh agent with the given call direction."""
    return RLMCaptureAgent(
        session_id=_SESSION_ID,
        call_control_id=_CALL_CTRL_ID,
        call_direction=call_direction,
    )


def _run(coro):
    """Run a coroutine synchronously (Python 3.7+)."""
    return asyncio.get_event_loop().run_until_complete(coro)


def _mock_httpx_response(payload: dict, status_code: int = 200) -> MagicMock:
    """
    Build a MagicMock that mimics an httpx.Response.
    .json() returns payload; .raise_for_status() is a no-op for 2xx.
    """
    mock_resp = MagicMock()
    mock_resp.status_code = status_code
    mock_resp.json.return_value = payload
    if status_code >= 400:
        import httpx
        mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
            message=f"HTTP {status_code}",
            request=MagicMock(),
            response=mock_resp,
        )
    else:
        mock_resp.raise_for_status.return_value = None
    return mock_resp


def _patch_httpx_get(response_mock: MagicMock):
    """
    Context-manager helper: patches httpx.AsyncClient so that .get()
    returns response_mock without making any real network calls.
    """
    mock_client = AsyncMock()
    mock_client.__aenter__ = AsyncMock(return_value=mock_client)
    mock_client.__aexit__ = AsyncMock(return_value=False)
    mock_client.get = AsyncMock(return_value=response_mock)
    return patch("httpx.AsyncClient", return_value=mock_client), mock_client


# ---------------------------------------------------------------------------
# BB1: Telnyx returns transcript → chunk dict returned with all 4 fields
# ---------------------------------------------------------------------------


class TestBB1_TranscriptReturned_ChunkDictCorrect:
    """BB1: Mock Telnyx returns a valid transcript → chunk dict with all 4 fields."""

    def _call(self, agent: RLMCaptureAgent, payload: dict) -> dict:
        resp = _mock_httpx_response(payload)
        ctx, _ = _patch_httpx_get(resp)
        with ctx:
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    return _run(agent._fetch_and_label_chunk())

    def test_returns_dict_not_none(self):
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": "Hello AIVA"}]}}
        result = self._call(agent, payload)
        assert result is not None
        assert isinstance(result, dict)

    def test_has_all_four_fields(self):
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": "Hello AIVA"}]}}
        result = self._call(agent, payload)
        assert "t" in result
        assert "speaker" in result
        assert "text" in result
        assert "chunk_index" in result

    def test_t_is_float_timestamp(self):
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": "Test call"}]}}
        before = time.time()
        result = self._call(agent, payload)
        after = time.time()
        assert isinstance(result["t"], float)
        assert before <= result["t"] <= after

    def test_text_matches_transcript_content(self):
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": "Book a quote"}]}}
        result = self._call(agent, payload)
        assert result["text"] == "Book a quote"

    def test_flat_transcript_fallback(self):
        """BB1 edge: when messages list absent, falls back to flat transcript string."""
        agent = _make_agent()
        payload = {"data": {"transcript": "How much does it cost?"}}
        result = self._call(agent, payload)
        assert result is not None
        assert result["text"] == "How much does it cost?"


# ---------------------------------------------------------------------------
# BB2: Telnyx returns empty transcript → None returned
# ---------------------------------------------------------------------------


class TestBB2_EmptyTranscript_NoneReturned:
    """BB2: Mock Telnyx returns empty or absent transcript → None returned."""

    def _call(self, agent: RLMCaptureAgent, payload: dict):
        resp = _mock_httpx_response(payload)
        ctx, _ = _patch_httpx_get(resp)
        with ctx:
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    return _run(agent._fetch_and_label_chunk())

    def test_empty_messages_list_returns_none(self):
        agent = _make_agent()
        payload = {"data": {"messages": []}}
        result = self._call(agent, payload)
        assert result is None

    def test_no_messages_no_transcript_returns_none(self):
        agent = _make_agent()
        payload = {"data": {}}
        result = self._call(agent, payload)
        assert result is None

    def test_empty_string_transcript_returns_none(self):
        agent = _make_agent()
        payload = {"data": {"transcript": ""}}
        result = self._call(agent, payload)
        assert result is None

    def test_empty_content_in_message_returns_none(self):
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": ""}]}}
        result = self._call(agent, payload)
        assert result is None

    def test_same_text_twice_returns_none_on_second(self):
        """Deduplication: same transcript returned twice → second call returns None."""
        agent = _make_agent()
        payload = {"data": {"messages": [{"role": "user", "content": "Are you open?"}]}}
        resp = _mock_httpx_response(payload)
        ctx, _ = _patch_httpx_get(resp)
        with ctx:
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    first = _run(agent._fetch_and_label_chunk())
                    second = _run(agent._fetch_and_label_chunk())
        assert first is not None
        assert second is None


# ---------------------------------------------------------------------------
# BB3: Speaker labeling — call direction determines human label
# ---------------------------------------------------------------------------


class TestBB3_SpeakerLabeling:
    """
    BB3: Speaker labeling rules:
        role == "assistant" → "AIVA"
        role != "assistant" + outbound call → "KINAN"
        role != "assistant" + inbound call → "CUSTOMER"
    """

    def _call(self, agent: RLMCaptureAgent, payload: dict):
        resp = _mock_httpx_response(payload)
        ctx, _ = _patch_httpx_get(resp)
        with ctx:
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    return _run(agent._fetch_and_label_chunk())

    def test_assistant_role_labeled_aiva(self):
        agent = _make_agent(call_direction="inbound")
        payload = {"data": {"messages": [{"role": "assistant", "content": "Hello, how can I help?"}]}}
        result = self._call(agent, payload)
        assert result["speaker"] == "AIVA"

    def test_user_role_inbound_labeled_customer(self):
        agent = _make_agent(call_direction="inbound")
        payload = {"data": {"messages": [{"role": "user", "content": "I need a quote"}]}}
        result = self._call(agent, payload)
        assert result["speaker"] == "CUSTOMER"

    def test_user_role_outbound_labeled_kinan(self):
        agent = _make_agent(call_direction="outbound")
        # Reset last transcript so dedup doesn't block
        payload = {"data": {"messages": [{"role": "user", "content": "Testing outbound call"}]}}
        result = self._call(agent, payload)
        assert result["speaker"] == "KINAN"

    def test_flat_transcript_inbound_labeled_customer(self):
        """Flat transcript (no role field) + inbound → CUSTOMER."""
        agent = _make_agent(call_direction="inbound")
        payload = {"data": {"transcript": "Call me back please"}}
        result = self._call(agent, payload)
        assert result["speaker"] == "CUSTOMER"

    def test_flat_transcript_outbound_labeled_kinan(self):
        """Flat transcript (no role field) + outbound → KINAN."""
        agent = _make_agent(call_direction="outbound")
        payload = {"data": {"transcript": "Following up on the quote"}}
        result = self._call(agent, payload)
        assert result["speaker"] == "KINAN"


# ---------------------------------------------------------------------------
# WB1: Telnyx API timeout → None returned (no exception propagated)
# ---------------------------------------------------------------------------


class TestWB1_ApiTimeout_NoneReturned:
    """WB1: Telnyx API times out → None returned, no exception escapes."""

    def test_timeout_returns_none(self):
        import httpx
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(
            side_effect=httpx.TimeoutException("Connection timed out")
        )

        agent = _make_agent()
        with patch("httpx.AsyncClient", return_value=mock_client):
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    result = _run(agent._fetch_and_label_chunk())

        assert result is None

    def test_timeout_does_not_raise(self):
        import httpx
        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(
            side_effect=httpx.TimeoutException("Timeout")
        )

        agent = _make_agent()
        try:
            with patch("httpx.AsyncClient", return_value=mock_client):
                with patch("os.environ.get", return_value="test-api-key"):
                    with patch.object(agent, "_log_event"):
                        _run(agent._fetch_and_label_chunk())
        except Exception as exc:
            pytest.fail(f"Timeout exception escaped: {exc}")


# ---------------------------------------------------------------------------
# WB2: Telnyx returns malformed JSON → None returned, error logged
# ---------------------------------------------------------------------------


class TestWB2_MalformedJson_NoneReturned:
    """WB2: httpx returns malformed or unexpected payload → None, error logged."""

    def test_json_parse_error_returns_none(self):
        import httpx
        mock_resp = MagicMock()
        mock_resp.raise_for_status.return_value = None
        mock_resp.json.side_effect = ValueError("No JSON object could be decoded")

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(return_value=mock_resp)

        agent = _make_agent()
        with patch("httpx.AsyncClient", return_value=mock_client):
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    result = _run(agent._fetch_and_label_chunk())

        assert result is None

    def test_http_4xx_returns_none(self):
        """HTTP 401 Unauthorized → None (no exception propagated)."""
        import httpx
        mock_resp = _mock_httpx_response({}, status_code=401)

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(return_value=mock_resp)

        agent = _make_agent()
        with patch("httpx.AsyncClient", return_value=mock_client):
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    result = _run(agent._fetch_and_label_chunk())

        assert result is None

    def test_error_does_not_propagate(self):
        """Any internal parse error must be caught — method never raises."""
        mock_resp = MagicMock()
        mock_resp.raise_for_status.return_value = None
        mock_resp.json.return_value = None  # payload.get() will raise AttributeError

        mock_client = AsyncMock()
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client.get = AsyncMock(return_value=mock_resp)

        agent = _make_agent()
        try:
            with patch("httpx.AsyncClient", return_value=mock_client):
                with patch("os.environ.get", return_value="test-api-key"):
                    with patch.object(agent, "_log_event"):
                        _run(agent._fetch_and_label_chunk())
        except Exception as exc:
            pytest.fail(f"Exception escaped _fetch_and_label_chunk: {exc}")


# ---------------------------------------------------------------------------
# WB3: chunk_index increments correctly across sequential calls
# ---------------------------------------------------------------------------


class TestWB3_ChunkIndexIncrements:
    """WB3: _chunk_index starts at 0 and increments by 1 on each successful fetch."""

    def _call_with_new_text(self, agent: RLMCaptureAgent, text: str):
        """Make one successful fetch call with a fresh text value."""
        payload = {"data": {"messages": [{"role": "user", "content": text}]}}
        resp = _mock_httpx_response(payload)
        ctx, _ = _patch_httpx_get(resp)
        with ctx:
            with patch("os.environ.get", return_value="test-api-key"):
                with patch.object(agent, "_log_event"):
                    return _run(agent._fetch_and_label_chunk())

    def test_initial_chunk_index_is_zero(self):
        agent = _make_agent()
        assert agent._chunk_index == 0

    def test_chunk_index_in_first_result_is_zero(self):
        agent = _make_agent()
        result = self._call_with_new_text(agent, "First message")
        assert result is not None
        assert result["chunk_index"] == 0

    def test_chunk_index_increments_to_one_after_first_call(self):
        agent = _make_agent()
        self._call_with_new_text(agent, "First message")
        assert agent._chunk_index == 1

    def test_chunk_index_in_second_result_is_one(self):
        agent = _make_agent()
        self._call_with_new_text(agent, "First message")
        result2 = self._call_with_new_text(agent, "Second message")
        assert result2 is not None
        assert result2["chunk_index"] == 1

    def test_chunk_index_increments_sequentially(self):
        agent = _make_agent()
        chunks = []
        for i in range(5):
            r = self._call_with_new_text(agent, f"Message number {i}")
            assert r is not None, f"Expected chunk at i={i}"
            chunks.append(r["chunk_index"])
        assert chunks == [0, 1, 2, 3, 4]

    def test_chunk_index_does_not_increment_on_none_return(self):
        """chunk_index must NOT increment when None is returned (empty/dupe transcript)."""
        agent = _make_agent()

        # First call: successful
        result1 = self._call_with_new_text(agent, "Same text")
        assert result1 is not None
        assert agent._chunk_index == 1

        # Second call with same text → None (deduplication)
        result2 = self._call_with_new_text(agent, "Same text")
        assert result2 is None
        # chunk_index must still be 1 — did not increment
        assert agent._chunk_index == 1


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
