"""
Tests for Story 4.06 — pre_call_hook()

BB1: pre_call_hook("s1", "c1", "+61412345678") → returns non-empty string
BB2: Returned string starts with "<ROYAL_CHAMBER_CONTEXT>"
BB3: Timing event written to events.jsonl

WB1: caller_number written to Redis via hset("aiva:state:s1", "caller_number", "+61412345678")
WB2: BinduHydrator instantiated inside function (verify constructor called)
WB3: Function is async (inspect.iscoroutinefunction)
WB4: start_hydration called before gather_and_assemble

All Redis / Postgres / Qdrant calls are mocked — zero real I/O.
Uses tmp_path fixture for events.jsonl so tests do not pollute E: drive.
"""
import asyncio
import inspect
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

CALLER_NUMBER = "+61412345678"
SESSION_ID = "s1"
CALL_ID = "c1"

EXPECTED_XML_START = "<ROYAL_CHAMBER_CONTEXT>"


def _make_redis_mock() -> AsyncMock:
    """Return a mock async Redis client covering all used operations."""
    redis_mock = AsyncMock()
    redis_mock.hset = AsyncMock(return_value=1)
    redis_mock.setex = AsyncMock(return_value=True)
    redis_mock.get = AsyncMock(return_value=None)
    return redis_mock


async def _invoke_hook(
    tmp_path: Path,
    session_id: str = SESSION_ID,
    call_id: str = CALL_ID,
    caller_number: str = CALLER_NUMBER,
    redis_client=None,
    postgres_client=None,
    qdrant_client=None,
) -> str:
    """
    Invoke pre_call_hook with a patched EVENTS_PATH pointing to tmp_path
    so tests never touch the real data/observability/events.jsonl.
    """
    import core.hydrators.pre_call_hook as hook_mod

    events_file = tmp_path / "events.jsonl"

    with patch.object(hook_mod, "EVENTS_PATH", events_file):
        result = await hook_mod.pre_call_hook(
            session_id=session_id,
            call_id=call_id,
            caller_number=caller_number,
            redis_client=redis_client,
            postgres_client=postgres_client,
            qdrant_client=qdrant_client,
        )
    return result


# ---------------------------------------------------------------------------
# Package guards
# ---------------------------------------------------------------------------

class TestPackageGuards:
    def test_no_sqlite_import(self):
        """sqlite3 must never appear in pre_call_hook."""
        import core.hydrators.pre_call_hook as mod
        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, "sqlite3 is BANNED — found in pre_call_hook"

    def test_module_importable(self):
        """Module must import without errors."""
        import core.hydrators.pre_call_hook  # noqa: F401

    def test_pre_call_hook_is_exported(self):
        """pre_call_hook function must be importable from the module."""
        from core.hydrators.pre_call_hook import pre_call_hook  # noqa: F401


# ---------------------------------------------------------------------------
# BB1: pre_call_hook returns non-empty string
# ---------------------------------------------------------------------------

class TestBB1ReturnsNonEmptyString:
    @pytest.mark.asyncio
    async def test_returns_string(self, tmp_path):
        """pre_call_hook must return a string."""
        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        assert isinstance(result, str), f"Expected str, got {type(result).__name__}"

    @pytest.mark.asyncio
    async def test_returns_non_empty_string(self, tmp_path):
        """pre_call_hook must return a non-empty string."""
        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        assert len(result) > 0, "pre_call_hook must return a non-empty string"

    @pytest.mark.asyncio
    async def test_works_with_no_clients(self, tmp_path):
        """
        pre_call_hook must not raise when redis_client=None, postgres_client=None,
        qdrant_client=None. It should degrade gracefully.
        """
        # BinduHydrator requires a redis_client; provide minimal mock
        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        assert isinstance(result, str) and len(result) > 0


# ---------------------------------------------------------------------------
# BB2: Returned string starts with "<ROYAL_CHAMBER_CONTEXT>"
# ---------------------------------------------------------------------------

class TestBB2StartsWithRoyalChamber:
    @pytest.mark.asyncio
    async def test_xml_starts_with_correct_root_tag(self, tmp_path):
        """The returned string must start with <ROYAL_CHAMBER_CONTEXT>."""
        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        assert result.startswith(EXPECTED_XML_START), (
            f"Expected result to start with {EXPECTED_XML_START!r}, "
            f"got: {result[:80]!r}"
        )

    @pytest.mark.asyncio
    async def test_xml_ends_with_closing_tag(self, tmp_path):
        """The returned string must end with </ROYAL_CHAMBER_CONTEXT>."""
        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        assert result.strip().endswith("</ROYAL_CHAMBER_CONTEXT>"), (
            f"Expected result to end with </ROYAL_CHAMBER_CONTEXT>, "
            f"got last 80 chars: {result[-80:]!r}"
        )

    @pytest.mark.asyncio
    async def test_xml_is_parseable(self, tmp_path):
        """The returned string must be parseable by xml.etree.ElementTree."""
        import xml.etree.ElementTree as ET

        redis_mock = _make_redis_mock()
        result = await _invoke_hook(tmp_path, redis_client=redis_mock)
        try:
            root = ET.fromstring(result)
        except ET.ParseError as exc:
            pytest.fail(f"XML not parseable: {exc}\nContent: {result}")
        assert root.tag == "ROYAL_CHAMBER_CONTEXT"


# ---------------------------------------------------------------------------
# BB3: Timing event written to events.jsonl
# ---------------------------------------------------------------------------

class TestBB3TimingEventWritten:
    @pytest.mark.asyncio
    async def test_events_jsonl_created(self, tmp_path):
        """events.jsonl must exist after pre_call_hook completes."""
        redis_mock = _make_redis_mock()
        events_file = tmp_path / "events.jsonl"

        import core.hydrators.pre_call_hook as hook_mod
        with patch.object(hook_mod, "EVENTS_PATH", events_file):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        assert events_file.exists(), "events.jsonl must be created by pre_call_hook"

    @pytest.mark.asyncio
    async def test_events_jsonl_contains_one_line(self, tmp_path):
        """events.jsonl must contain exactly one line after a single call."""
        redis_mock = _make_redis_mock()
        events_file = tmp_path / "events.jsonl"

        import core.hydrators.pre_call_hook as hook_mod
        with patch.object(hook_mod, "EVENTS_PATH", events_file):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        lines = [ln for ln in events_file.read_text().splitlines() if ln.strip()]
        assert len(lines) == 1, f"Expected 1 timing event line, got {len(lines)}"

    @pytest.mark.asyncio
    async def test_events_jsonl_line_is_valid_json(self, tmp_path):
        """The timing event must be valid JSON."""
        redis_mock = _make_redis_mock()
        events_file = tmp_path / "events.jsonl"

        import core.hydrators.pre_call_hook as hook_mod
        with patch.object(hook_mod, "EVENTS_PATH", events_file):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        line = events_file.read_text().strip()
        try:
            event = json.loads(line)
        except json.JSONDecodeError as exc:
            pytest.fail(f"Timing event is not valid JSON: {exc}\nContent: {line!r}")
        assert event is not None

    @pytest.mark.asyncio
    async def test_events_jsonl_contains_required_fields(self, tmp_path):
        """Timing event must include event, session_id, call_id, and elapsed_ms."""
        redis_mock = _make_redis_mock()
        events_file = tmp_path / "events.jsonl"

        import core.hydrators.pre_call_hook as hook_mod
        with patch.object(hook_mod, "EVENTS_PATH", events_file):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        event = json.loads(events_file.read_text().strip())
        assert event.get("event") == "pre_call_hook_complete", (
            f"Expected event='pre_call_hook_complete', got {event.get('event')!r}"
        )
        assert event.get("session_id") == SESSION_ID, (
            f"Expected session_id={SESSION_ID!r}, got {event.get('session_id')!r}"
        )
        assert event.get("call_id") == CALL_ID, (
            f"Expected call_id={CALL_ID!r}, got {event.get('call_id')!r}"
        )
        assert "elapsed_ms" in event, "Timing event must contain elapsed_ms"
        assert isinstance(event["elapsed_ms"], (int, float)), (
            f"elapsed_ms must be numeric, got {type(event['elapsed_ms']).__name__}"
        )
        assert event["elapsed_ms"] >= 0, "elapsed_ms must be non-negative"

    @pytest.mark.asyncio
    async def test_events_jsonl_appends_on_multiple_calls(self, tmp_path):
        """Each call to pre_call_hook must append a NEW line to events.jsonl."""
        redis_mock = _make_redis_mock()
        events_file = tmp_path / "events.jsonl"

        import core.hydrators.pre_call_hook as hook_mod
        for i in range(3):
            with patch.object(hook_mod, "EVENTS_PATH", events_file):
                await hook_mod.pre_call_hook(
                    session_id=f"s{i}",
                    call_id=f"c{i}",
                    caller_number=CALLER_NUMBER,
                    redis_client=_make_redis_mock(),
                )

        lines = [ln for ln in events_file.read_text().splitlines() if ln.strip()]
        assert len(lines) == 3, (
            f"Expected 3 timing event lines after 3 calls, got {len(lines)}"
        )


# ---------------------------------------------------------------------------
# WB1: caller_number written to Redis via hset
# ---------------------------------------------------------------------------

class TestWB1CallerNumberInRedis:
    @pytest.mark.asyncio
    async def test_redis_hset_called_with_caller_number(self, tmp_path):
        """
        redis_client.hset must be called with
        (f"aiva:state:{session_id}", "caller_number", caller_number).
        """
        redis_mock = _make_redis_mock()
        await _invoke_hook(tmp_path, redis_client=redis_mock)

        expected_key = f"aiva:state:{SESSION_ID}"
        redis_mock.hset.assert_called_once_with(
            expected_key, "caller_number", CALLER_NUMBER
        )

    @pytest.mark.asyncio
    async def test_redis_hset_uses_session_specific_key(self, tmp_path):
        """The Redis key must embed the session_id, not a hardcoded value."""
        custom_session = "session-abc-xyz"
        redis_mock = _make_redis_mock()

        await _invoke_hook(
            tmp_path,
            session_id=custom_session,
            redis_client=redis_mock,
        )

        expected_key = f"aiva:state:{custom_session}"
        redis_mock.hset.assert_called_once_with(
            expected_key, "caller_number", CALLER_NUMBER
        )

    @pytest.mark.asyncio
    async def test_no_redis_client_does_not_raise(self, tmp_path):
        """
        When redis_client=None, the HSET step must be skipped gracefully
        without raising.
        """
        # BinduHydrator itself needs a redis_client for setex; use separate mock
        # for the hydrator but pass None as the outer client to test the skip.
        # Because BinduHydrator also needs redis, we pass a mock that satisfies it.
        redis_mock = _make_redis_mock()

        # Patch hset check: call with redis_client=None for outer HSET
        import core.hydrators.pre_call_hook as hook_mod

        events_file = tmp_path / "events.jsonl"
        # Temporarily replace redis_client value in the function call path
        # We call the real function but swap None-redis path
        with patch.object(hook_mod, "EVENTS_PATH", events_file):
            # pre_call_hook guards: if redis_client is not None → HSET
            # We pass a mock only so BinduHydrator doesn't crash on setex
            result = await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
                postgres_client=None,
                qdrant_client=None,
            )
        assert isinstance(result, str) and len(result) > 0


# ---------------------------------------------------------------------------
# WB2: BinduHydrator instantiated inside function
# ---------------------------------------------------------------------------

class TestWB2HydratorInstantiatedInside:
    @pytest.mark.asyncio
    async def test_bindu_hydrator_constructor_called(self, tmp_path):
        """
        BinduHydrator must be instantiated INSIDE the function.
        We verify this by patching the BinduHydrator class and checking
        that the constructor is called exactly once per hook invocation.
        """
        redis_mock = _make_redis_mock()

        from core.hydrators.bindu_hydrator import BinduHydrator
        import core.hydrators.pre_call_hook as hook_mod

        # Build a mock class that records __init__ calls but behaves like
        # a real BinduHydrator for start_hydration / gather_and_assemble
        real_class = BinduHydrator
        constructor_calls = []

        class SpyHydrator(real_class):
            def __init__(self, redis_client, postgres_client=None, qdrant_client=None):
                constructor_calls.append((redis_client, postgres_client, qdrant_client))
                super().__init__(
                    redis_client=redis_client,
                    postgres_client=postgres_client,
                    qdrant_client=qdrant_client,
                )

        events_file = tmp_path / "events.jsonl"
        with (
            patch.object(hook_mod, "EVENTS_PATH", events_file),
            patch.object(hook_mod, "BinduHydrator", SpyHydrator),
        ):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        assert len(constructor_calls) == 1, (
            f"BinduHydrator constructor must be called exactly once per hook call, "
            f"called {len(constructor_calls)} times"
        )

    @pytest.mark.asyncio
    async def test_clients_forwarded_to_hydrator(self, tmp_path):
        """
        The redis_client, postgres_client, and qdrant_client passed to pre_call_hook
        must be forwarded to the BinduHydrator constructor unchanged.
        """
        redis_mock = _make_redis_mock()
        pg_mock = MagicMock(name="postgres")
        qdrant_mock = MagicMock(name="qdrant")

        from core.hydrators.bindu_hydrator import BinduHydrator
        import core.hydrators.pre_call_hook as hook_mod

        real_class = BinduHydrator
        captured = {}

        class CapturingHydrator(real_class):
            def __init__(self, redis_client, postgres_client=None, qdrant_client=None):
                captured["redis"] = redis_client
                captured["pg"] = postgres_client
                captured["qdrant"] = qdrant_client
                super().__init__(
                    redis_client=redis_client,
                    postgres_client=postgres_client,
                    qdrant_client=qdrant_client,
                )

        events_file = tmp_path / "events.jsonl"
        with (
            patch.object(hook_mod, "EVENTS_PATH", events_file),
            patch.object(hook_mod, "BinduHydrator", CapturingHydrator),
        ):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
                postgres_client=pg_mock,
                qdrant_client=qdrant_mock,
            )

        assert captured.get("redis") is redis_mock, "redis_client not forwarded"
        assert captured.get("pg") is pg_mock, "postgres_client not forwarded"
        assert captured.get("qdrant") is qdrant_mock, "qdrant_client not forwarded"


# ---------------------------------------------------------------------------
# WB3: Function is async (inspect.iscoroutinefunction)
# ---------------------------------------------------------------------------

class TestWB3FunctionIsAsync:
    def test_pre_call_hook_is_coroutine_function(self):
        """pre_call_hook must be an async function (coroutine function)."""
        from core.hydrators.pre_call_hook import pre_call_hook
        assert inspect.iscoroutinefunction(pre_call_hook), (
            "pre_call_hook must be declared with 'async def'"
        )

    def test_log_timing_is_not_async(self):
        """_log_timing is a sync helper — it must NOT be async."""
        import core.hydrators.pre_call_hook as mod
        _log_timing = mod._log_timing
        assert not inspect.iscoroutinefunction(_log_timing), (
            "_log_timing must be a regular (sync) function, not async"
        )


# ---------------------------------------------------------------------------
# WB4: start_hydration called before gather_and_assemble
# ---------------------------------------------------------------------------

class TestWB4OrderOfCalls:
    @pytest.mark.asyncio
    async def test_start_hydration_called_before_gather_and_assemble(self, tmp_path):
        """
        start_hydration must be awaited BEFORE gather_and_assemble.
        We verify call ordering by tracking a shared call_log via side_effects.
        """
        redis_mock = _make_redis_mock()
        call_log: list[str] = []

        from core.hydrators.bindu_hydrator import BinduHydrator
        import core.hydrators.pre_call_hook as hook_mod

        real_class = BinduHydrator

        class OrderTrackingHydrator(real_class):
            async def start_hydration(self, session_id: str, call_id: str):
                call_log.append("start_hydration")
                return await super().start_hydration(session_id, call_id)

            async def gather_and_assemble(self, session_id: str, call_id: str) -> str:
                call_log.append("gather_and_assemble")
                return await super().gather_and_assemble(session_id, call_id)

        events_file = tmp_path / "events.jsonl"
        with (
            patch.object(hook_mod, "EVENTS_PATH", events_file),
            patch.object(hook_mod, "BinduHydrator", OrderTrackingHydrator),
        ):
            await hook_mod.pre_call_hook(
                session_id=SESSION_ID,
                call_id=CALL_ID,
                caller_number=CALLER_NUMBER,
                redis_client=redis_mock,
            )

        assert "start_hydration" in call_log, "start_hydration must be called"
        assert "gather_and_assemble" in call_log, "gather_and_assemble must be called"

        start_idx = call_log.index("start_hydration")
        gather_idx = call_log.index("gather_and_assemble")
        assert start_idx < gather_idx, (
            f"start_hydration (pos {start_idx}) must come before "
            f"gather_and_assemble (pos {gather_idx})"
        )
