#!/usr/bin/env python3
"""
Tests for Story 3.09: PostCallEnricher — Qdrant Write + Deadline Handler
AIVA RLM Nexus PRD v2 — Track A

Black-box tests (BB1-BB4): verify the public enrich() contract from the
outside, using only mocked collaborators.

White-box tests (WB1-WB5): verify Qdrant collection name, embedding dims,
payload content, UUID format, and __init__ signature.

ALL external calls (Redis, Gemini, Qdrant) are fully mocked.
Zero real I/O in this test suite.
"""
import asyncio
import json
import sys
import uuid
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-09-test"
_FAKE_API_KEY = "fake-gemini-api-key-309"

_VALID_ENRICHMENT = {
    "summary": "Customer called about Invoice 1234 from last month.",
    "entities": ["Invoice 1234"],
    "decisions_made": ["Look up invoice status"],
    "action_items": [{"task": "Resolve invoice", "owner": "Agent", "deadline": "EOD"}],
    "emotional_signal": "neutral",
    "key_facts": ["Invoice number 1234"],
    "kinan_directives": [],
}

_SAMPLE_CHUNKS = [
    json.dumps({"speaker": "CUSTOMER", "text": "Hello, I need help with my invoice.", "t": 1700000001.0}),
    json.dumps({"speaker": "AGENT", "text": "Of course! Which invoice?", "t": 1700000005.0}),
    json.dumps({"speaker": "CUSTOMER", "text": "Invoice 1234.", "t": 1700000010.0}),
]


# ---------------------------------------------------------------------------
# FakePointStruct: a lightweight stand-in that captures constructor args
# ---------------------------------------------------------------------------

class FakePointStruct:
    """Captures the arguments passed to PointStruct for assertion in tests."""

    def __init__(self, id, vector, payload):
        self.id = id
        self.vector = vector
        self.payload = payload


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_redis(chunks=None) -> AsyncMock:
    """Return an async-mock Redis client."""
    redis = AsyncMock()
    redis.lrange = AsyncMock(return_value=chunks if chunks is not None else [])
    return redis


def _make_qdrant_mock(upsert_ok: bool = True) -> MagicMock:
    """Return a synchronous mock Qdrant client."""
    mock_qdrant = MagicMock()
    if upsert_ok:
        mock_qdrant.upsert.return_value = MagicMock()
    else:
        mock_qdrant.upsert.side_effect = Exception("Qdrant connection refused")
    return mock_qdrant


def _make_enricher(
    chunks=None,
    qdrant_client=None,
    api_key: str = _FAKE_API_KEY,
) -> PostCallEnricher:
    """Return a PostCallEnricher with fully mocked Redis and optional Qdrant."""
    redis = _make_redis(chunks=chunks)
    return PostCallEnricher(
        redis_client=redis,
        gemini_api_key=api_key,
        qdrant_client=qdrant_client,
    )


def _run(coro):
    """Run a coroutine synchronously in a new event loop."""
    loop = asyncio.new_event_loop()
    try:
        return loop.run_until_complete(coro)
    finally:
        loop.close()


def _enrich_with_qdrant(enricher, qdrant_mock=None, enrichment=None):
    """
    Run enrich() with:
    - _enrich_with_gemini mocked to return enrichment (default: _VALID_ENRICHMENT)
    - qdrant_client.models.PointStruct replaced with FakePointStruct
    """
    if enrichment is None:
        enrichment = _VALID_ENRICHMENT

    with patch.object(enricher, "_enrich_with_gemini", new=AsyncMock(return_value=enrichment)):
        with patch("qdrant_client.models.PointStruct", FakePointStruct):
            return _run(enricher.enrich(_SESSION_ID))


def _capture_upsert_payload(qdrant_mock) -> dict:
    """Extract the FakePointStruct payload from the Qdrant mock's upsert call."""
    call_kwargs = qdrant_mock.upsert.call_args[1]
    points = call_kwargs["points"]
    assert len(points) == 1
    return points[0].payload


def _capture_upsert_point(qdrant_mock) -> FakePointStruct:
    """Extract the entire FakePointStruct from the Qdrant mock's upsert call."""
    call_kwargs = qdrant_mock.upsert.call_args[1]
    return call_kwargs["points"][0]


# ---------------------------------------------------------------------------
# BB1: enrich() with valid transcript → Qdrant upsert called, returns UUID string
# ---------------------------------------------------------------------------


class TestBB1_ValidTranscript_QdrantUpsert:
    """
    BB1: Valid enrichment + mocked Gemini + working Qdrant mock →
    enrich() calls upsert and returns a non-empty UUID string.
    """

    def test_returns_string(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        assert isinstance(result, str) and len(result) > 0

    def test_qdrant_upsert_called(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        assert qdrant.upsert.called, "Qdrant upsert must be called for valid transcript"

    def test_result_is_valid_uuid_string(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        parsed = uuid.UUID(result)  # raises ValueError if invalid
        assert str(parsed) == result


# ---------------------------------------------------------------------------
# BB2: enrich() with empty transcript → returns None, Qdrant NOT called
# ---------------------------------------------------------------------------


class TestBB2_EmptyTranscript_NoQdrantWrite:
    """
    BB2: Redis returns [] → empty enrichment summary → enrich() returns None,
    no Qdrant write attempted.
    """

    def test_returns_none_on_empty_transcript(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(chunks=[], qdrant_client=qdrant)
        result = _run(enricher.enrich(_SESSION_ID))
        assert result is None

    def test_qdrant_not_called_on_empty_transcript(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(chunks=[], qdrant_client=qdrant)
        _run(enricher.enrich(_SESSION_ID))
        qdrant.upsert.assert_not_called()

    def test_no_exception_raised_on_empty_transcript(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(chunks=[], qdrant_client=qdrant)
        try:
            _run(enricher.enrich(_SESSION_ID))
        except Exception as exc:
            pytest.fail(f"enrich() raised unexpectedly on empty transcript: {exc}")


# ---------------------------------------------------------------------------
# BB3: Qdrant write fails → returns None (not exception)
# ---------------------------------------------------------------------------


class TestBB3_QdrantFails_ReturnsNone:
    """
    BB3: Qdrant.upsert() raises → enrich() catches it and returns None.
    Also: EnrichmentError from Gemini → returns None. Both are non-fatal.
    """

    def test_returns_none_on_qdrant_failure(self):
        qdrant = _make_qdrant_mock(upsert_ok=False)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        assert result is None

    def test_no_exception_raised_on_qdrant_failure(self):
        qdrant = _make_qdrant_mock(upsert_ok=False)
        enricher = _make_enricher(qdrant_client=qdrant)
        try:
            _enrich_with_qdrant(enricher, qdrant)
        except Exception as exc:
            pytest.fail(f"enrich() raised on Qdrant failure: {exc}")

    def test_enrichment_error_also_returns_none(self):
        """EnrichmentError from Gemini → enrich() returns None (non-fatal)."""
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        with patch.object(
            enricher,
            "_enrich_with_gemini",
            new=AsyncMock(side_effect=EnrichmentError("Gemini API down")),
        ):
            result = _run(enricher.enrich(_SESSION_ID))
        assert result is None


# ---------------------------------------------------------------------------
# BB4: Returned UUID is valid UUID4 format
# ---------------------------------------------------------------------------


class TestBB4_ReturnedUUID_IsValidUUID4:
    """
    BB4: The UUID string returned by enrich() is well-formed (parseable as UUID).
    """

    def test_uuid_is_parseable(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        parsed = uuid.UUID(result)
        assert parsed is not None

    def test_uuid_format_is_8_4_4_4_12(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        parts = result.split("-")
        assert len(parts) == 5, f"UUID must have 5 dash-separated parts, got: {parts}"
        assert len(parts[0]) == 8
        assert len(parts[1]) == 4
        assert len(parts[2]) == 4
        assert len(parts[3]) == 4
        assert len(parts[4]) == 12


# ---------------------------------------------------------------------------
# WB1: Qdrant upsert called with collection_name="aiva_conversations"
# ---------------------------------------------------------------------------


class TestWB1_QdrantCollection_IsAivaConversations:
    """
    WB1: The upsert call must target collection_name="aiva_conversations" exactly.
    """

    def test_collection_name_is_aiva_conversations(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        call_kwargs = qdrant.upsert.call_args[1]
        assert call_kwargs.get("collection_name") == "aiva_conversations", (
            f"Expected 'aiva_conversations', got: {call_kwargs.get('collection_name')}"
        )

    def test_class_constant_is_aiva_conversations(self):
        """The class constant must be 'aiva_conversations' — guards against typos."""
        assert PostCallEnricher._QDRANT_COLLECTION == "aiva_conversations"


# ---------------------------------------------------------------------------
# WB2: Embedding is 768-dim
# ---------------------------------------------------------------------------


class TestWB2_Embedding_Is768Dim:
    """
    WB2: _embed_text returns a list of exactly 768 floats in [-1, 1].
    """

    def test_embed_text_returns_768_floats(self):
        enricher = _make_enricher()
        vector = enricher._embed_text("Customer called about their invoice.")
        assert len(vector) == 768, f"Expected 768, got {len(vector)}"

    def test_embed_text_elements_are_floats(self):
        enricher = _make_enricher()
        vector = enricher._embed_text("Test summary text.")
        assert all(isinstance(v, float) for v in vector), "All elements must be float"

    def test_embed_text_is_deterministic(self):
        enricher = _make_enricher()
        text = "Same input text produces same output."
        assert enricher._embed_text(text) == enricher._embed_text(text)

    def test_embed_text_different_inputs_produce_different_vectors(self):
        enricher = _make_enricher()
        v1 = enricher._embed_text("First unique text A B C")
        v2 = enricher._embed_text("Second unique text X Y Z")
        assert v1 != v2, "Different inputs must produce different vectors"

    def test_embed_text_values_in_range(self):
        enricher = _make_enricher()
        vector = enricher._embed_text("Range test for embedding output.")
        assert all(-1.0 <= v <= 1.0 for v in vector), "All values must be in [-1, 1]"


# ---------------------------------------------------------------------------
# WB3: Qdrant payload contains enrichment fields (summary, entities, etc.)
# ---------------------------------------------------------------------------


class TestWB3_QdrantPayload_ContainsEnrichmentFields:
    """
    WB3: The PointStruct payload must contain all 7 enrichment fields
    plus session_id and vector_id.
    """

    _EXPECTED_FIELDS = (
        "summary", "entities", "decisions_made", "action_items",
        "emotional_signal", "key_facts", "kinan_directives",
    )

    def test_payload_contains_summary(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        assert "summary" in payload

    def test_payload_contains_all_enrichment_fields(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        for field in self._EXPECTED_FIELDS:
            assert field in payload, f"Missing enrichment field in payload: {field}"

    def test_payload_contains_session_id(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        assert "session_id" in payload
        assert payload["session_id"] == _SESSION_ID

    def test_upsert_called_exactly_once(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        qdrant.upsert.assert_called_once()


# ---------------------------------------------------------------------------
# WB4: vector_id in payload is UUID4 format; matches returned value
# ---------------------------------------------------------------------------


class TestWB4_VectorID_IsUUID4:
    """
    WB4: The vector_id stored in the Qdrant payload is a valid UUID string
    that matches the value returned from enrich().
    """

    def test_payload_has_vector_id(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        assert "vector_id" in payload

    def test_payload_vector_id_is_valid_uuid(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        uuid.UUID(payload["vector_id"])  # raises ValueError if invalid

    def test_returned_id_matches_payload_vector_id(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        payload = _capture_upsert_payload(qdrant)
        assert result == payload["vector_id"]

    def test_qdrant_point_id_matches_returned_uuid(self):
        qdrant = _make_qdrant_mock(upsert_ok=True)
        enricher = _make_enricher(qdrant_client=qdrant)
        result = _enrich_with_qdrant(enricher, qdrant)
        point = _capture_upsert_point(qdrant)
        assert point.id == result


# ---------------------------------------------------------------------------
# WB5: qdrant_client is an optional __init__ parameter (defaults to None)
# ---------------------------------------------------------------------------


class TestWB5_Init_QdrantClientOptional:
    """
    WB5: PostCallEnricher can be constructed with or without a qdrant_client.
    When omitted, _qdrant is None and enrich() returns None.
    """

    def test_init_without_qdrant_client_does_not_raise(self):
        redis = AsyncMock()
        enricher = PostCallEnricher(redis_client=redis, gemini_api_key="test")
        assert enricher is not None

    def test_qdrant_defaults_to_none(self):
        redis = AsyncMock()
        enricher = PostCallEnricher(redis_client=redis, gemini_api_key="test")
        assert enricher._qdrant is None

    def test_qdrant_client_stored_when_provided(self):
        redis = AsyncMock()
        mock_qdrant = MagicMock()
        enricher = PostCallEnricher(
            redis_client=redis, gemini_api_key="test", qdrant_client=mock_qdrant
        )
        assert enricher._qdrant is mock_qdrant

    def test_enrich_returns_none_without_qdrant_client(self):
        """enrich() with None qdrant_client + valid enrichment → None (no place to write)."""
        redis = _make_redis(chunks=_SAMPLE_CHUNKS)
        enricher = PostCallEnricher(
            redis_client=redis, gemini_api_key=_FAKE_API_KEY, qdrant_client=None
        )
        with patch.object(enricher, "_enrich_with_gemini", new=AsyncMock(return_value=_VALID_ENRICHMENT)):
            result = _run(enricher.enrich(_SESSION_ID))
        assert result is None


# ---------------------------------------------------------------------------
# Package export (regression: Story 3.08 exports still intact)
# ---------------------------------------------------------------------------


class TestPackageExport:
    """Verify that both classes are still 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


# ---------------------------------------------------------------------------
# No SQLite
# ---------------------------------------------------------------------------


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)
