#!/usr/bin/env python3
"""
Tests for Story 3.08: PostCallEnricher — Gemini Enrichment
AIVA RLM Nexus PRD v2 — Track A

Black-box tests (BB1-BB3): verify the public enrichment contract from the
outside, using only mocked collaborators.

White-box tests (WB1-WB5): verify internal parsing paths, fallback behaviour,
action_item structure, Redis key format, and Gemini model selection.

ALL external calls (Redis, Gemini API) are fully mocked.
Zero real I/O in this test suite.
"""
import asyncio
import json
import sys
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from core.enrichers import PostCallEnricher, EnrichmentError
from core.enrichers.post_call_enricher import PostCallEnricher as DirectImport

# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------

_SESSION_ID = "sess-3-08-test"
_REDIS_KEY = f"aiva:transcript:{_SESSION_ID}"
_FAKE_API_KEY = "fake-gemini-api-key"

_SAMPLE_CHUNKS = [
    json.dumps({"speaker": "CUSTOMER", "text": "Hi, I need to book a plumber for tomorrow.", "t": 1700000001.0, "chunk_index": 0}),
    json.dumps({"speaker": "AGENT", "text": "Sure! What time works best for you?", "t": 1700000005.0, "chunk_index": 1}),
    json.dumps({"speaker": "CUSTOMER", "text": "9am would be great. My name is John Smith.", "t": 1700000010.0, "chunk_index": 2}),
    json.dumps({"speaker": "AGENT", "text": "Confirmed — plumber booked for tomorrow at 9am. See you then!", "t": 1700000015.0, "chunk_index": 3}),
    json.dumps({"speaker": "CUSTOMER", "text": "Perfect. Can you also send a reminder?", "t": 1700000020.0, "chunk_index": 4}),
]

_VALID_ENRICHMENT_JSON = json.dumps({
    "summary": "Customer John Smith booked a plumber for tomorrow at 9am and requested a reminder.",
    "entities": ["John Smith", "plumber"],
    "decisions_made": ["Book plumber for 9am tomorrow"],
    "action_items": [
        {"task": "Send booking reminder", "owner": "AGENT", "deadline": "tomorrow morning"},
    ],
    "emotional_signal": "positive",
    "key_facts": ["Appointment at 9am", "Customer is John Smith"],
    "kinan_directives": [],
})

_ALL_7_FIELDS = (
    "summary",
    "entities",
    "decisions_made",
    "action_items",
    "emotional_signal",
    "key_facts",
    "kinan_directives",
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_redis(chunks=None, lrange_side_effect=None) -> AsyncMock:
    """Return an async-mock Redis client."""
    redis = AsyncMock()
    if lrange_side_effect is not None:
        redis.lrange = AsyncMock(side_effect=lrange_side_effect)
    else:
        redis.lrange = AsyncMock(return_value=chunks if chunks is not None else [])
    return redis


def _make_enricher(redis=None, api_key: str = _FAKE_API_KEY) -> PostCallEnricher:
    """Return a fresh PostCallEnricher with mocked Redis."""
    if redis is None:
        redis = _make_redis()
    return PostCallEnricher(redis_client=redis, gemini_api_key=api_key)


def _run(coro):
    """Run a coroutine synchronously."""
    return asyncio.get_event_loop().run_until_complete(coro)


# ---------------------------------------------------------------------------
# BB1: Mocked 5-chunk transcript → all 7 fields populated with correct types
# ---------------------------------------------------------------------------


class TestBB1_FullTranscript_AllFieldsPopulated:
    """
    BB1: A mocked transcript of 5 chunks passed through a mocked Gemini call
    returns a dict with all 7 required fields and correct Python types.
    """

    def test_returns_dict(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert isinstance(result, dict)

    def test_all_seven_fields_present(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        for field in _ALL_7_FIELDS:
            assert field in result, f"Missing field: {field}"

    def test_summary_is_str(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert isinstance(result["summary"], str)
        assert len(result["summary"]) > 0

    def test_list_fields_are_lists(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        for field in ("entities", "decisions_made", "action_items", "key_facts", "kinan_directives"):
            assert isinstance(result[field], list), f"Field {field!r} must be a list"

    def test_emotional_signal_is_str(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert isinstance(result["emotional_signal"], str)


# ---------------------------------------------------------------------------
# BB2: Empty Redis transcript → graceful empty enrichment object
# ---------------------------------------------------------------------------


class TestBB2_EmptyTranscript_GracefulEmpty:
    """
    BB2: LRANGE returns [] (empty list) → returns graceful empty enrichment.
    No exception raised. All 7 fields present with correct empty defaults.
    """

    def test_does_not_raise_on_empty_transcript(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        try:
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))
        except Exception as exc:
            pytest.fail(f"Raised unexpectedly on empty transcript: {exc}")

    def test_summary_is_empty_call_message(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert result["summary"] == "Empty call — no transcript captured"

    def test_all_list_fields_empty_on_empty_transcript(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        for field in ("entities", "decisions_made", "action_items", "key_facts", "kinan_directives"):
            assert result[field] == [], f"Field {field!r} must be [] on empty transcript"

    def test_all_seven_fields_present_on_empty_transcript(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        for field in _ALL_7_FIELDS:
            assert field in result, f"Missing field on empty transcript: {field}"

    def test_gemini_not_called_on_empty_transcript(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock()) as mock_gemini:
            _run(enricher._enrich_with_gemini(_SESSION_ID))

        mock_gemini.assert_not_called()


# ---------------------------------------------------------------------------
# BB3: Gemini returns valid JSON → all fields parsed correctly
# ---------------------------------------------------------------------------


class TestBB3_ValidGeminiJson_FieldsParsed:
    """
    BB3: Gemini returns a well-formed JSON string → parsed dict matches
    the expected field values exactly.
    """

    def test_summary_extracted_correctly(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        expected_summary = json.loads(_VALID_ENRICHMENT_JSON)["summary"]
        assert result["summary"] == expected_summary

    def test_entities_extracted_correctly(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert "John Smith" in result["entities"]
        assert "plumber" in result["entities"]

    def test_emotional_signal_extracted_correctly(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert result["emotional_signal"] == "positive"

    def test_action_items_extracted_correctly(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT_JSON)):
            result = _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert len(result["action_items"]) == 1
        item = result["action_items"][0]
        assert item["task"] == "Send booking reminder"
        assert item["owner"] == "AGENT"
        assert item["deadline"] == "tomorrow morning"


# ---------------------------------------------------------------------------
# WB1: Gemini returns malformed JSON → fallback summary, no exception raised
# ---------------------------------------------------------------------------


class TestWB1_MalformedJson_FallbackSummary:
    """
    WB1: _parse_gemini_response receives invalid JSON → falls back to
    {"summary": raw_response, "entities": [], ...} without raising.
    """

    def test_malformed_json_does_not_raise(self):
        enricher = _make_enricher()
        raw = "This is not valid JSON at all { broken"

        try:
            result = enricher._parse_gemini_response(raw)
        except Exception as exc:
            pytest.fail(f"_parse_gemini_response raised on malformed JSON: {exc}")

    def test_malformed_json_summary_is_raw_response(self):
        enricher = _make_enricher()
        raw = "Gemini said something but forgot to JSON-encode it."

        result = enricher._parse_gemini_response(raw)

        assert result["summary"] == raw

    def test_malformed_json_list_fields_are_empty(self):
        enricher = _make_enricher()
        raw = "{ invalid json }"

        result = enricher._parse_gemini_response(raw)

        for field in ("entities", "decisions_made", "action_items", "key_facts", "kinan_directives"):
            assert result[field] == [], f"Field {field!r} must be [] on malformed JSON"

    def test_malformed_json_all_seven_fields_present(self):
        enricher = _make_enricher()
        raw = "garbage response"

        result = enricher._parse_gemini_response(raw)

        for field in _ALL_7_FIELDS:
            assert field in result, f"Missing field on malformed JSON: {field}"

    def test_non_object_json_falls_back(self):
        """Gemini returns a JSON array instead of an object — must still fall back."""
        enricher = _make_enricher()
        raw = json.dumps(["not", "an", "object"])

        result = enricher._parse_gemini_response(raw)

        # summary should be the raw string (non-object triggers fallback)
        assert result["summary"] == raw


# ---------------------------------------------------------------------------
# WB2: Gemini call raises exception → EnrichmentError raised
# ---------------------------------------------------------------------------


class TestWB2_GeminiCallRaises_EnrichmentError:
    """
    WB2: _call_gemini() raises an exception → _enrich_with_gemini() must
    propagate it as EnrichmentError (not swallow it silently).
    """

    def test_gemini_exception_raises_enrichment_error(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(side_effect=RuntimeError("API down"))):
            with pytest.raises(EnrichmentError):
                _run(enricher._enrich_with_gemini(_SESSION_ID))

    def test_enrichment_error_contains_session_id(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(side_effect=ConnectionError("timeout"))):
            with pytest.raises(EnrichmentError) as exc_info:
                _run(enricher._enrich_with_gemini(_SESSION_ID))

        assert _SESSION_ID in str(exc_info.value)

    def test_enrichment_error_is_exception_subclass(self):
        assert issubclass(EnrichmentError, Exception)

    def test_connection_error_wrapped_as_enrichment_error(self):
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = _make_enricher(redis=redis)

        with patch.object(enricher, "_call_gemini", new=AsyncMock(side_effect=OSError("network failure"))):
            with pytest.raises(EnrichmentError) as exc_info:
                _run(enricher._enrich_with_gemini(_SESSION_ID))

        # Original cause must be preserved
        assert exc_info.value.__cause__ is not None


# ---------------------------------------------------------------------------
# WB3: action_items each have "task", "owner", "deadline" keys
# ---------------------------------------------------------------------------


class TestWB3_ActionItems_CorrectStructure:
    """
    WB3: action_items in the parsed response must each be dicts with exactly
    the three required keys: task, owner, deadline.
    """

    def _build_response_with_actions(self, items: list) -> str:
        return json.dumps({
            "summary": "test",
            "entities": [],
            "decisions_made": [],
            "action_items": items,
            "emotional_signal": "neutral",
            "key_facts": [],
            "kinan_directives": [],
        })

    def test_action_item_has_task_key(self):
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Follow up with client", "owner": "Agent", "deadline": "Friday"}
        ])
        result = enricher._parse_gemini_response(raw)

        assert "task" in result["action_items"][0]

    def test_action_item_has_owner_key(self):
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Send invoice", "owner": "Finance", "deadline": "EOD"}
        ])
        result = enricher._parse_gemini_response(raw)

        assert "owner" in result["action_items"][0]

    def test_action_item_has_deadline_key(self):
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Book follow-up", "owner": "Receptionist", "deadline": "next Monday"}
        ])
        result = enricher._parse_gemini_response(raw)

        assert "deadline" in result["action_items"][0]

    def test_action_item_values_are_strings(self):
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Call back", "owner": "AIVA", "deadline": "ASAP"}
        ])
        result = enricher._parse_gemini_response(raw)

        item = result["action_items"][0]
        assert isinstance(item["task"], str)
        assert isinstance(item["owner"], str)
        assert isinstance(item["deadline"], str)

    def test_multiple_action_items_all_structured(self):
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Task A", "owner": "Alice", "deadline": "Monday"},
            {"task": "Task B", "owner": "Bob", "deadline": ""},
            {"task": "Task C", "owner": "", "deadline": "Friday"},
        ])
        result = enricher._parse_gemini_response(raw)

        assert len(result["action_items"]) == 3
        for item in result["action_items"]:
            assert set(item.keys()) >= {"task", "owner", "deadline"}

    def test_action_item_missing_deadline_defaults_to_empty_string(self):
        """Gemini omits deadline → normalise to empty string, not KeyError."""
        enricher = _make_enricher()
        raw = self._build_response_with_actions([
            {"task": "Do something", "owner": "AIVA"}  # no deadline key
        ])
        result = enricher._parse_gemini_response(raw)

        assert result["action_items"][0]["deadline"] == ""


# ---------------------------------------------------------------------------
# WB4: LRANGE called with correct key pattern
# ---------------------------------------------------------------------------


class TestWB4_LrangeKeyPattern:
    """
    WB4: _enrich_with_gemini must call redis.lrange() with the exact key
    pattern aiva:transcript:{session_id}, 0, -1.
    """

    def test_lrange_called_with_correct_key(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        _run(enricher._enrich_with_gemini(_SESSION_ID))

        redis.lrange.assert_called_once_with(_REDIS_KEY, 0, -1)

    def test_key_starts_with_aiva_transcript_prefix(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        _run(enricher._enrich_with_gemini(_SESSION_ID))

        called_key = redis.lrange.call_args[0][0]
        assert called_key.startswith("aiva:transcript:")

    def test_key_ends_with_session_id(self):
        redis = _make_redis(chunks=[])
        enricher = _make_enricher(redis=redis)

        _run(enricher._enrich_with_gemini(_SESSION_ID))

        called_key = redis.lrange.call_args[0][0]
        assert called_key.endswith(_SESSION_ID)

    def test_different_session_id_generates_different_key(self):
        """Key must incorporate the actual session_id value."""
        other_session = "sess-other-999"
        redis = _make_redis(chunks=[])
        enricher = PostCallEnricher(redis_client=redis, gemini_api_key=_FAKE_API_KEY)

        _run(enricher._enrich_with_gemini(other_session))

        called_key = redis.lrange.call_args[0][0]
        assert called_key == f"aiva:transcript:{other_session}"


# ---------------------------------------------------------------------------
# WB5: Gemini model used is "gemini-2.0-flash"
# ---------------------------------------------------------------------------


class TestWB5_GeminiModelIsFlash:
    """
    WB5: The GEMINI_MODEL class attribute must be 'gemini-2.0-flash'.
    The prompt builder must reference no other model.
    """

    def test_gemini_model_class_attribute(self):
        assert PostCallEnricher.GEMINI_MODEL == "gemini-2.0-flash"

    def test_enricher_instance_model_attribute(self):
        enricher = _make_enricher()
        assert enricher.GEMINI_MODEL == "gemini-2.0-flash"

    def test_model_is_not_pro(self):
        assert "pro" not in PostCallEnricher.GEMINI_MODEL.lower()

    def test_model_is_not_lite(self):
        assert "lite" not in PostCallEnricher.GEMINI_MODEL.lower()


# ---------------------------------------------------------------------------
# Package export test: from core.enrichers import PostCallEnricher, EnrichmentError
# ---------------------------------------------------------------------------


class TestPackageExport:
    """Verify that both classes are importable from the package root."""

    def test_post_call_enricher_importable_from_package(self):
        from core.enrichers import PostCallEnricher as PCE
        assert PCE is not None

    def test_enrichment_error_importable_from_package(self):
        from core.enrichers import EnrichmentError as EE
        assert EE is not None

    def test_direct_module_import_works(self):
        from core.enrichers.post_call_enricher import PostCallEnricher, EnrichmentError
        assert PostCallEnricher is not None
        assert EnrichmentError is not None

    def test_direct_import_matches_package_import(self):
        from core.enrichers import PostCallEnricher as PCE
        from core.enrichers.post_call_enricher import PostCallEnricher as PCE2
        assert PCE is PCE2


# ---------------------------------------------------------------------------
# No SQLite test
# ---------------------------------------------------------------------------


class TestNoSQLite:
    """Verify the implementation never imports sqlite3."""

    def test_no_sqlite_import(self):
        import ast
        import pathlib

        impl_path = pathlib.Path("/mnt/e/genesis-system/core/enrichers/post_call_enricher.py")
        source = impl_path.read_text(encoding="utf-8")
        tree = ast.parse(source)

        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for alias in node.names:
                    assert alias.name != "sqlite3", "sqlite3 import found in implementation!"
            elif isinstance(node, ast.ImportFrom):
                assert node.module != "sqlite3", "from sqlite3 import found in implementation!"


# ---------------------------------------------------------------------------
# Run summary
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
