"""
Tests for Story 4.05 — BinduHydrator.gather_and_assemble()

BB1: All tasks succeed → full XML with all 4 required sections
BB2: Qdrant task raises Exception → XML assembled with empty RELATED_SCARS
BB3: Redis key aiva:context:{session_id} set after gather_and_assemble() returns
BB4: Postgres task returns None → LAST_CONVERSATION contains literal "none"
BB5: All tasks fail → XML still assembled with safe defaults for all sections

WB1: asyncio.gather called with return_exceptions=True (patch gather)
WB2: XML is valid and parseable by xml.etree.ElementTree
WB3: Fallback values used when individual task returns an Exception instance
WB4: Redis SETEX called with TTL=300 seconds
WB5: HydrationSession is returned by start_hydration with status="pending"
     (gather_and_assemble does not break the session lifecycle)

All Redis / Postgres / Qdrant calls are mocked — zero real I/O.
"""
import asyncio
import json
import xml.etree.ElementTree as ET

import pytest
from unittest.mock import AsyncMock, MagicMock, patch, call


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _make_hydrator(*, redis_client=None, postgres_client=None, qdrant_client=None):
    """Return a BinduHydrator with fully mocked clients."""
    from core.hydrators.bindu_hydrator import BinduHydrator

    if redis_client is None:
        redis_client = AsyncMock()
        redis_client.setex = AsyncMock(return_value=True)
        redis_client.get = AsyncMock(return_value=None)

    return BinduHydrator(
        redis_client=redis_client,
        postgres_client=postgres_client,
        qdrant_client=qdrant_client,
    )


def _parse_xml(xml_str: str) -> ET.Element:
    """Helper: parse XML or raise with a helpful message."""
    try:
        return ET.fromstring(xml_str)
    except ET.ParseError as exc:
        raise AssertionError(f"XML not parseable: {exc}\nContent:\n{xml_str}") from exc


def _get_section_text(root: ET.Element, tag: str) -> str:
    """Extract text content of a child element by tag name."""
    elem = root.find(tag)
    assert elem is not None, f"Missing <{tag}> section in XML"
    return elem.text or ""


SESSION_ID = "sess-test-405"
CALL_ID = "call-test-405"


# ---------------------------------------------------------------------------
# Package guards (shared across all story tests)
# ---------------------------------------------------------------------------

class TestPackageGuards:
    def test_no_sqlite_import(self):
        """sqlite3 must never appear in bindu_hydrator."""
        import core.hydrators.bindu_hydrator as mod
        import inspect
        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, "sqlite3 is BANNED — found in bindu_hydrator"

    def test_module_importable(self):
        """Package must import without errors."""
        import core.hydrators.bindu_hydrator  # noqa: F401


# ---------------------------------------------------------------------------
# BB1: All tasks succeed → full XML with all 4 required sections
# ---------------------------------------------------------------------------

class TestBB1AllTasksSucceed:
    @pytest.mark.asyncio
    async def test_full_xml_all_sections_present(self):
        """
        When all 3 scatter tasks return valid data, gather_and_assemble must
        produce a ROYAL_CHAMBER_CONTEXT XML with all 4 child elements populated.
        """
        aiva_state = {"mood": "focused", "task": "answering calls"}
        kinan_directives = ["be concise", "book the appointment"]
        pg_row = {"conversation_id": "conv-001", "summary": "discussed pricing"}
        qdrant_hits = [{"chunk_text": "pricing scar", "score": 0.9,
                        "conversation_id": "conv-100", "timestamp": "2026-01-01T00:00:00Z"}]

        hydrator = _make_hydrator()

        async def _fake_redis(sid):
            return {"aiva_state": aiva_state, "kinan_directives": kinan_directives}

        async def _fake_postgres():
            return pg_row

        async def _fake_qdrant(query, top_k=3):
            return qdrant_hits

        hydrator._scatter_redis_tasks = _fake_redis
        hydrator._scatter_postgres_task = _fake_postgres
        hydrator._scatter_qdrant_task = _fake_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        assert root.tag == "ROYAL_CHAMBER_CONTEXT", f"Wrong root tag: {root.tag!r}"

        required_sections = {
            "AIVA_WORKING_STATE", "KING_DIRECTIVES",
            "LAST_CONVERSATION", "RELATED_SCARS",
        }
        present = {child.tag for child in root}
        missing = required_sections - present
        assert not missing, f"Missing XML sections: {missing}"

    @pytest.mark.asyncio
    async def test_aiva_state_content_matches(self):
        """AIVA_WORKING_STATE text must be valid JSON matching the redis aiva_state."""
        aiva_state = {"status": "live", "uptime": 42}
        hydrator = _make_hydrator()

        async def _fake_redis(sid):
            return {"aiva_state": aiva_state, "kinan_directives": []}

        async def _fake_postgres():
            return None

        async def _fake_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _fake_redis
        hydrator._scatter_postgres_task = _fake_postgres
        hydrator._scatter_qdrant_task = _fake_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        raw = _get_section_text(root, "AIVA_WORKING_STATE")
        parsed = json.loads(raw)
        assert parsed == aiva_state, f"AIVA_WORKING_STATE mismatch: {parsed!r}"


# ---------------------------------------------------------------------------
# BB2: Qdrant task raises Exception → XML assembled with empty RELATED_SCARS
# ---------------------------------------------------------------------------

class TestBB2QdrantFails:
    @pytest.mark.asyncio
    async def test_qdrant_exception_gives_empty_related_scars(self):
        """
        When _scatter_qdrant_task raises RuntimeError, gather_and_assemble must:
        - Still produce valid XML
        - RELATED_SCARS must be the JSON for an empty list ("[]")
        """
        hydrator = _make_hydrator()

        async def _fake_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _fake_postgres():
            return None

        async def _fake_qdrant_fail(query, top_k=3):
            raise RuntimeError("Qdrant connection refused")

        hydrator._scatter_redis_tasks = _fake_redis
        hydrator._scatter_postgres_task = _fake_postgres
        hydrator._scatter_qdrant_task = _fake_qdrant_fail

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        scars_text = _get_section_text(root, "RELATED_SCARS")
        assert scars_text == "[]", (
            f"Expected RELATED_SCARS='[]' when Qdrant fails, got: {scars_text!r}"
        )

    @pytest.mark.asyncio
    async def test_other_sections_still_populated_when_qdrant_fails(self):
        """Other sections must still be assembled even when Qdrant fails."""
        aiva_state = {"key": "value"}
        hydrator = _make_hydrator()

        async def _fake_redis(sid):
            return {"aiva_state": aiva_state, "kinan_directives": ["directive-1"]}

        async def _fake_postgres():
            return {"conversation_id": "c-1"}

        async def _failing_qdrant(query, top_k=3):
            raise ConnectionError("timeout")

        hydrator._scatter_redis_tasks = _fake_redis
        hydrator._scatter_postgres_task = _fake_postgres
        hydrator._scatter_qdrant_task = _failing_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        # AIVA_WORKING_STATE and KING_DIRECTIVES must be real data
        aiva_parsed = json.loads(_get_section_text(root, "AIVA_WORKING_STATE"))
        assert aiva_parsed == aiva_state

        directives_parsed = json.loads(_get_section_text(root, "KING_DIRECTIVES"))
        assert directives_parsed == ["directive-1"]


# ---------------------------------------------------------------------------
# BB3: Redis key aiva:context:{session_id} set after gather_and_assemble()
# ---------------------------------------------------------------------------

class TestBB3RedisKeySet:
    @pytest.mark.asyncio
    async def test_redis_setex_called_with_correct_key(self):
        """
        After gather_and_assemble completes, Redis SETEX must have been called
        with key "aiva:context:{session_id}".
        """
        redis_mock = AsyncMock()
        redis_mock.setex = AsyncMock(return_value=True)
        redis_mock.get = AsyncMock(return_value=None)

        hydrator = _make_hydrator(redis_client=redis_mock)

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        expected_key = f"aiva:context:{SESSION_ID}"
        # setex may have been called once for start_hydration and once here;
        # we check the LAST call (which is the XML write).
        all_calls = redis_mock.setex.call_args_list
        assert any(
            c.args[0] == expected_key or c.kwargs.get("name") == expected_key
            for c in all_calls
        ), (
            f"Redis SETEX never called with key '{expected_key}'. "
            f"Calls were: {[str(c) for c in all_calls]}"
        )

    @pytest.mark.asyncio
    async def test_redis_setex_value_is_xml_string(self):
        """The value written to Redis must be the XML string (not 'pending')."""
        redis_mock = AsyncMock()
        redis_mock.setex = AsyncMock(return_value=True)
        redis_mock.get = AsyncMock(return_value=None)

        hydrator = _make_hydrator(redis_client=redis_mock)

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        # Find the setex call that writes XML (value starts with <ROYAL_CHAMBER)
        xml_write_calls = [
            c for c in redis_mock.setex.call_args_list
            if len(c.args) >= 3 and isinstance(c.args[2], str) and c.args[2].startswith("<ROYAL_CHAMBER_CONTEXT>")
        ]
        assert xml_write_calls, (
            "No Redis SETEX call found whose value is the ROYAL_CHAMBER_CONTEXT XML"
        )


# ---------------------------------------------------------------------------
# BB4: Postgres returns None → LAST_CONVERSATION contains literal "none"
# ---------------------------------------------------------------------------

class TestBB4PostgresNone:
    @pytest.mark.asyncio
    async def test_last_conversation_is_none_when_postgres_returns_null(self):
        """
        When _scatter_postgres_task returns None, LAST_CONVERSATION must contain
        the literal string "none" (not empty, not "null").
        """
        hydrator = _make_hydrator()

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _null_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _null_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        last_conv = _get_section_text(root, "LAST_CONVERSATION")
        assert last_conv == "none", (
            f"Expected LAST_CONVERSATION='none' when postgres returns None, got: {last_conv!r}"
        )


# ---------------------------------------------------------------------------
# BB5: All tasks fail → XML still assembled with safe defaults
# ---------------------------------------------------------------------------

class TestBB5AllTasksFail:
    @pytest.mark.asyncio
    async def test_all_tasks_fail_produces_valid_xml_with_defaults(self):
        """
        When all 3 scatter tasks raise exceptions, gather_and_assemble must:
        - NOT raise
        - Return valid XML with all 4 sections using safe defaults:
            AIVA_WORKING_STATE = {}
            KING_DIRECTIVES    = []
            LAST_CONVERSATION  = "none"
            RELATED_SCARS      = []
        """
        hydrator = _make_hydrator()

        async def _failing_redis(sid):
            raise ConnectionError("Redis down")

        async def _failing_postgres():
            raise TimeoutError("PG timeout")

        async def _failing_qdrant(query, top_k=3):
            raise RuntimeError("Qdrant down")

        hydrator._scatter_redis_tasks = _failing_redis
        hydrator._scatter_postgres_task = _failing_postgres
        hydrator._scatter_qdrant_task = _failing_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        # Must be valid XML
        root = _parse_xml(xml_str)

        aiva_state = json.loads(_get_section_text(root, "AIVA_WORKING_STATE"))
        king_dir = json.loads(_get_section_text(root, "KING_DIRECTIVES"))
        last_conv = _get_section_text(root, "LAST_CONVERSATION")
        scars = json.loads(_get_section_text(root, "RELATED_SCARS"))

        assert aiva_state == {}, f"Expected empty dict, got: {aiva_state!r}"
        assert king_dir == [], f"Expected empty list, got: {king_dir!r}"
        assert last_conv == "none", f"Expected 'none', got: {last_conv!r}"
        assert scars == [], f"Expected empty list, got: {scars!r}"


# ---------------------------------------------------------------------------
# WB1: asyncio.gather called with return_exceptions=True
# ---------------------------------------------------------------------------

class TestWB1GatherReturnExceptions:
    @pytest.mark.asyncio
    async def test_gather_called_with_return_exceptions_true(self):
        """
        gather_and_assemble must use asyncio.gather(..., return_exceptions=True)
        so that individual task failures are captured as values, not propagated.
        We patch asyncio.gather and inspect the keyword arguments.
        """
        hydrator = _make_hydrator()

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        original_gather = asyncio.gather
        gather_kwargs_captured = []

        async def _spy_gather(*coros, **kwargs):
            gather_kwargs_captured.append(kwargs)
            return await original_gather(*coros, **kwargs)

        with patch("asyncio.gather", side_effect=_spy_gather):
            await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        assert any(
            kw.get("return_exceptions") is True
            for kw in gather_kwargs_captured
        ), (
            f"asyncio.gather never called with return_exceptions=True. "
            f"Captured kwargs: {gather_kwargs_captured}"
        )


# ---------------------------------------------------------------------------
# WB2: XML is valid and parseable by xml.etree.ElementTree
# ---------------------------------------------------------------------------

class TestWB2XmlValid:
    @pytest.mark.asyncio
    async def test_xml_parseable_by_stdlib(self):
        """The returned string must be parseable by xml.etree.ElementTree.fromstring."""
        hydrator = _make_hydrator()

        async def _ok_redis(sid):
            return {"aiva_state": {"x": 1}, "kinan_directives": ["d1"]}

        async def _ok_postgres():
            return {"conversation_id": "conv-2", "summary": "pricing talk"}

        async def _ok_qdrant(query, top_k=3):
            return [{"chunk_text": "remember this", "score": 0.88,
                     "conversation_id": "conv-3", "timestamp": "2026-01-10T00:00:00Z"}]

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        # Must not raise
        root = ET.fromstring(xml_str)
        assert root is not None

    @pytest.mark.asyncio
    async def test_xml_has_correct_root_tag(self):
        """Root element tag must be ROYAL_CHAMBER_CONTEXT."""
        hydrator = _make_hydrator()

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = ET.fromstring(xml_str)
        assert root.tag == "ROYAL_CHAMBER_CONTEXT", f"Wrong root tag: {root.tag!r}"


# ---------------------------------------------------------------------------
# WB3: Fallback values used when a task returns an Exception instance
# ---------------------------------------------------------------------------

class TestWB3FallbackOnException:
    @pytest.mark.asyncio
    async def test_redis_exception_uses_empty_state_and_directives(self):
        """
        When _scatter_redis_tasks is captured as an Exception by return_exceptions=True,
        gather_and_assemble must use aiva_state={} and kinan_directives=[].
        """
        hydrator = _make_hydrator()

        # We test the exception-as-value path directly by making gather return
        # an Exception object in the redis position.
        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        # Override _scatter_redis_tasks to raise — gather(return_exceptions=True) captures it
        async def _raising_redis(sid):
            raise ValueError("Redis key missing")

        hydrator._scatter_redis_tasks = _raising_redis

        xml_str = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)
        root = _parse_xml(xml_str)

        aiva_state = json.loads(_get_section_text(root, "AIVA_WORKING_STATE"))
        king_dir = json.loads(_get_section_text(root, "KING_DIRECTIVES"))

        assert aiva_state == {}, f"Expected {{}}, got: {aiva_state!r}"
        assert king_dir == [], f"Expected [], got: {king_dir!r}"


# ---------------------------------------------------------------------------
# WB4: Redis SETEX called with 300s TTL
# ---------------------------------------------------------------------------

class TestWB4RedisTTL:
    @pytest.mark.asyncio
    async def test_setex_called_with_300_second_ttl(self):
        """
        The Redis SETEX that caches the final XML must use TTL=300 (5 minutes),
        matching _CONTEXT_TTL_SECONDS.
        """
        redis_mock = AsyncMock()
        redis_mock.setex = AsyncMock(return_value=True)
        redis_mock.get = AsyncMock(return_value=None)

        hydrator = _make_hydrator(redis_client=redis_mock)

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        # Find any SETEX call that writes the XML (TTL should be 300)
        xml_write_calls = [
            c for c in redis_mock.setex.call_args_list
            if len(c.args) >= 3 and isinstance(c.args[2], str)
            and c.args[2].startswith("<ROYAL_CHAMBER_CONTEXT>")
        ]
        assert xml_write_calls, "No SETEX call found for the XML envelope"

        for c in xml_write_calls:
            ttl = c.args[1]
            assert ttl == 300, f"Expected TTL=300, got TTL={ttl!r}"


# ---------------------------------------------------------------------------
# WB5: start_hydration still works (session lifecycle not broken)
# ---------------------------------------------------------------------------

class TestWB5SessionLifecycle:
    @pytest.mark.asyncio
    async def test_start_hydration_returns_pending_session(self):
        """
        start_hydration must return a HydrationSession with status='pending'.
        gather_and_assemble builds on top of this — verifying they coexist.
        """
        from core.hydrators.bindu_hydrator import HydrationSession

        redis_mock = AsyncMock()
        redis_mock.setex = AsyncMock(return_value=True)
        redis_mock.get = AsyncMock(return_value=None)

        hydrator = _make_hydrator(redis_client=redis_mock)

        session = await hydrator.start_hydration(SESSION_ID, CALL_ID)

        assert isinstance(session, HydrationSession)
        assert session.status == "pending"
        assert session.session_id == SESSION_ID
        assert session.aiva_call_id == CALL_ID

    @pytest.mark.asyncio
    async def test_gather_and_assemble_returns_string(self):
        """gather_and_assemble must return a non-empty string."""
        hydrator = _make_hydrator()

        async def _ok_redis(sid):
            return {"aiva_state": {}, "kinan_directives": []}

        async def _ok_postgres():
            return None

        async def _ok_qdrant(query, top_k=3):
            return []

        hydrator._scatter_redis_tasks = _ok_redis
        hydrator._scatter_postgres_task = _ok_postgres
        hydrator._scatter_qdrant_task = _ok_qdrant

        result = await hydrator.gather_and_assemble(SESSION_ID, CALL_ID)

        assert isinstance(result, str), f"Expected str, got {type(result).__name__}"
        assert len(result) > 0, "XML string must not be empty"
        assert result.startswith("<ROYAL_CHAMBER_CONTEXT>"), (
            f"XML must start with <ROYAL_CHAMBER_CONTEXT>, got: {result[:80]!r}"
        )
