"""
tests/track_a/test_story_5_05.py

Story 5.05 — SwarmRouter: dispatch() Method

Black-box tests  (BB1–BB3): validate external behaviour via public API
White-box tests  (WB1–WB4): validate internal implementation details

All external dependencies are fully mocked.
No real I/O (events.jsonl path is patched to tmp_path in every test).
No network, no database.
"""

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

import asyncio
import json
import pytest
from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch, call

from core.intent.intent_signal import IntentType, IntentSignal
from core.routing.swarm_router import SwarmRouter


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_signal(
    intent_type: IntentType,
    confidence: float = 0.9,
    session_id: str = "test-session-505",
    utterance: str = "I need to book a job",
) -> IntentSignal:
    """Factory that creates a minimal IntentSignal for testing."""
    return IntentSignal(
        session_id=session_id,
        utterance=utterance,
        intent_type=intent_type,
        confidence=confidence,
        extracted_entities={},
        requires_swarm=True,
        created_at=datetime(2026, 2, 25, 12, 0, 0),
        raw_gemini_response=None,
    )


def _make_router(tmp_path: Path, extra_workers: dict | None = None) -> SwarmRouter:
    """
    Factory that creates a SwarmRouter with a fully-mocked worker registry.
    Each mock worker has `execute` set to an AsyncMock returning {"status": "ok"}.
    The EVENTS_PATH module constant is patched to write into tmp_path instead.
    """
    booking_worker = MagicMock(name="BookingWorker")
    booking_worker.execute = AsyncMock(return_value={"status": "ok"})

    lead_worker = MagicMock(name="LeadQualificationWorker")
    lead_worker.execute = AsyncMock(return_value={"status": "ok"})

    faq_worker = MagicMock(name="FAQWorker")
    faq_worker.execute = AsyncMock(return_value={"status": "ok"})

    escalation_worker = MagicMock(name="EscalationWorker")
    escalation_worker.execute = AsyncMock(return_value={"status": "ok"})

    memory_worker = MagicMock(name="MemoryCaptureWorker")
    memory_worker.execute = AsyncMock(return_value={"status": "ok"})

    genesis_worker = MagicMock(name="GenesisTaskWorker")
    genesis_worker.execute = AsyncMock(return_value={"status": "ok"})

    registry = {
        "BookingWorker":           booking_worker,
        "LeadQualificationWorker": lead_worker,
        "FAQWorker":               faq_worker,
        "EscalationWorker":        escalation_worker,
        "MemoryCaptureWorker":     memory_worker,
        "GenesisTaskWorker":       genesis_worker,
    }
    if extra_workers:
        registry.update(extra_workers)
    return SwarmRouter(worker_registry=registry)


# ---------------------------------------------------------------------------
# BB1: BOOK_JOB signal, confidence=0.9 → task returned (not None)
# ---------------------------------------------------------------------------

class TestBB1_SuccessfulDispatch:
    """BB1 — dispatch() returns an asyncio.Task for a routable intent."""

    @pytest.mark.asyncio
    async def test_book_job_returns_task(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            result = await router.dispatch(signal)
        assert result is not None

    @pytest.mark.asyncio
    async def test_book_job_result_is_asyncio_task(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            result = await router.dispatch(signal)
        assert isinstance(result, asyncio.Task)
        await result  # cleanly drain the task to avoid ResourceWarning

    @pytest.mark.asyncio
    async def test_task_result_is_worker_return_value(self, tmp_path):
        """The Task, when awaited, must resolve to the worker's return value."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)
        result = await task
        assert result == {"status": "ok"}

    @pytest.mark.asyncio
    async def test_dispatch_at_threshold_confidence_returns_task(self, tmp_path):
        """Exactly at CONFIDENCE_THRESHOLD=0.6 must succeed."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.6)
            result = await router.dispatch(signal)
        assert result is not None
        await result


# ---------------------------------------------------------------------------
# BB2: UNKNOWN signal → None returned, no task created
# ---------------------------------------------------------------------------

class TestBB2_UnknownIntentDispatch:
    """BB2 — dispatch() returns None for UNKNOWN intent type."""

    @pytest.mark.asyncio
    async def test_unknown_returns_none(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.UNKNOWN, confidence=0.9)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_unknown_high_confidence_still_none(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.UNKNOWN, confidence=1.0)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_unknown_does_not_call_any_worker(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.UNKNOWN, confidence=0.9)
            await router.dispatch(signal)
        for worker in router.workers.values():
            worker.execute.assert_not_called()


# ---------------------------------------------------------------------------
# BB3: Low-confidence signal (0.4) → None returned
# ---------------------------------------------------------------------------

class TestBB3_LowConfidenceDispatch:
    """BB3 — dispatch() returns None for below-threshold confidence."""

    @pytest.mark.asyncio
    async def test_low_confidence_returns_none(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.4)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_zero_confidence_returns_none(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.QUALIFY_LEAD, confidence=0.0)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_just_below_threshold_returns_none(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.599)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_low_confidence_does_not_call_any_worker(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.4)
            await router.dispatch(signal)
        for worker in router.workers.values():
            worker.execute.assert_not_called()


# ---------------------------------------------------------------------------
# WB1: asyncio.create_task() used (not await worker.execute() directly)
# ---------------------------------------------------------------------------

class TestWB1_CreateTaskNotDirectAwait:
    """WB1 — dispatch() uses asyncio.create_task(), not direct await."""

    @pytest.mark.asyncio
    async def test_create_task_is_called(self, tmp_path):
        """Verify asyncio.create_task is invoked — not a plain coroutine await."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            with patch("asyncio.create_task", wraps=asyncio.create_task) as mock_create:
                router = _make_router(tmp_path)
                signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
                task = await router.dispatch(signal)
                mock_create.assert_called_once()
        await task  # drain cleanly

    @pytest.mark.asyncio
    async def test_dispatch_returns_before_worker_completes(self, tmp_path):
        """
        dispatch() must return the Task immediately (create_task semantics).
        We verify by using a slow coroutine — dispatch must resolve fast.
        """
        import time

        async def slow_execute(intent):
            await asyncio.sleep(0.05)
            return {"status": "slow"}

        events_file = tmp_path / "events.jsonl"
        slow_worker = MagicMock(name="SlowWorker")
        slow_worker.execute = slow_execute

        registry = {"BookingWorker": slow_worker}
        router = SwarmRouter(worker_registry=registry)

        signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)

        start = time.monotonic()
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            task = await router.dispatch(signal)
        elapsed = time.monotonic() - start

        # dispatch itself should return almost instantly (well under 50ms)
        assert elapsed < 0.04, f"dispatch() blocked for {elapsed:.3f}s — looks like await was used"
        await task  # drain the slow task cleanly


# ---------------------------------------------------------------------------
# WB2: observability log entry written with intent_type and session_id
# ---------------------------------------------------------------------------

class TestWB2_ObservabilityLog:
    """WB2 — _log_dispatch writes a valid JSONL entry to the events file."""

    @pytest.mark.asyncio
    async def test_events_file_created_after_dispatch(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)
        assert events_file.exists()
        await task

    @pytest.mark.asyncio
    async def test_log_entry_is_valid_json(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        lines = events_file.read_text().strip().splitlines()
        assert len(lines) == 1
        entry = json.loads(lines[0])
        assert isinstance(entry, dict)
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_intent_type(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert entry["intent_type"] == "book_job"
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_session_id(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(
                IntentType.BOOK_JOB,
                confidence=0.9,
                session_id="unique-session-abc",
            )
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert entry["session_id"] == "unique-session-abc"
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_worker_name(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert entry["worker_name"] == "BookingWorker"
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_confidence(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.85)
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert entry["confidence"] == pytest.approx(0.85)
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_event_field(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert entry["event"] == "dispatch"
        await task

    @pytest.mark.asyncio
    async def test_log_entry_contains_timestamp(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        entry = json.loads(events_file.read_text().strip())
        assert "timestamp" in entry
        assert isinstance(entry["timestamp"], str)
        assert "T" in entry["timestamp"]  # ISO 8601 format check
        await task

    @pytest.mark.asyncio
    async def test_no_log_entry_written_when_cannot_route(self, tmp_path):
        """No log entry should be written when can_route() returns False."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.UNKNOWN, confidence=0.9)
            await router.dispatch(signal)
        assert not events_file.exists()

    @pytest.mark.asyncio
    async def test_multiple_dispatches_append_multiple_lines(self, tmp_path):
        """Each successful dispatch appends one line to the events file."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            tasks = []
            for _ in range(3):
                signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
                task = await router.dispatch(signal)
                tasks.append(task)

        lines = [l for l in events_file.read_text().strip().splitlines() if l]
        assert len(lines) == 3
        for task in tasks:
            await task


# ---------------------------------------------------------------------------
# WB3: Worker receives the full IntentSignal object as argument
# ---------------------------------------------------------------------------

class TestWB3_WorkerReceivesFullSignal:
    """WB3 — worker.execute() is called with the complete IntentSignal."""

    @pytest.mark.asyncio
    async def test_worker_receives_intent_signal(self, tmp_path):
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            task = await router.dispatch(signal)

        booking_worker = router.workers["BookingWorker"]
        booking_worker.execute.assert_called_once_with(signal)
        await task

    @pytest.mark.asyncio
    async def test_worker_receives_exact_same_object(self, tmp_path):
        """The signal passed to execute must be the same object, not a copy."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.QUALIFY_LEAD, confidence=0.9)
            task = await router.dispatch(signal)

        lead_worker = router.workers["LeadQualificationWorker"]
        args, _ = lead_worker.execute.call_args
        assert args[0] is signal  # identity check — same object
        await task

    @pytest.mark.asyncio
    async def test_correct_worker_called_for_each_intent(self, tmp_path):
        """Each intent type routes to its designated worker, not another."""
        intent_to_worker = {
            IntentType.BOOK_JOB:       "BookingWorker",
            IntentType.QUALIFY_LEAD:   "LeadQualificationWorker",
            IntentType.ANSWER_FAQ:     "FAQWorker",
            IntentType.ESCALATE_HUMAN: "EscalationWorker",
            IntentType.CAPTURE_MEMORY: "MemoryCaptureWorker",
            IntentType.TASK_DISPATCH:  "GenesisTaskWorker",
        }
        events_file = tmp_path / "events.jsonl"
        for intent_type, expected_worker_name in intent_to_worker.items():
            router = _make_router(tmp_path)  # fresh router each time
            signal = _make_signal(intent_type, confidence=0.9)
            with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
                task = await router.dispatch(signal)

            called_worker = router.workers[expected_worker_name]
            called_worker.execute.assert_called_once_with(signal)
            await task


# ---------------------------------------------------------------------------
# WB4: _log_dispatch called after task creation
# ---------------------------------------------------------------------------

class TestWB4_LogDispatchCalledAfterTaskCreation:
    """WB4 — _log_dispatch() is invoked after asyncio.create_task()."""

    @pytest.mark.asyncio
    async def test_log_dispatch_called_on_success(self, tmp_path):
        """_log_dispatch must be called exactly once per successful dispatch."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)

            with patch.object(router, "_log_dispatch", wraps=router._log_dispatch) as mock_log:
                task = await router.dispatch(signal)
                mock_log.assert_called_once_with(signal, "BookingWorker")

        await task

    @pytest.mark.asyncio
    async def test_log_dispatch_not_called_on_cannot_route(self, tmp_path):
        """_log_dispatch must NOT be called when routing fails."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.UNKNOWN, confidence=0.9)

            with patch.object(router, "_log_dispatch") as mock_log:
                await router.dispatch(signal)
                mock_log.assert_not_called()

    @pytest.mark.asyncio
    async def test_log_dispatch_not_called_on_low_confidence(self, tmp_path):
        """_log_dispatch must NOT be called when confidence check fails."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.3)

            with patch.object(router, "_log_dispatch") as mock_log:
                await router.dispatch(signal)
                mock_log.assert_not_called()

    @pytest.mark.asyncio
    async def test_log_dispatch_not_called_when_worker_missing(self, tmp_path):
        """_log_dispatch must NOT be called if the worker instance is absent."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            # Empty registry — worker lookup will return None
            router = SwarmRouter(worker_registry={})
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)

            with patch.object(router, "_log_dispatch") as mock_log:
                result = await router.dispatch(signal)
                mock_log.assert_not_called()

        assert result is None

    @pytest.mark.asyncio
    async def test_task_created_before_log(self, tmp_path):
        """
        Verify ordering: create_task fires first, _log_dispatch second.
        We confirm this by checking that the task exists in the call chain.
        """
        events_file = tmp_path / "events.jsonl"
        creation_order = []

        async def fake_execute(intent):
            return {"status": "ok"}

        mock_worker = MagicMock()
        mock_worker.execute = fake_execute

        original_create_task = asyncio.create_task

        def tracking_create_task(coro, **kwargs):
            creation_order.append("create_task")
            return original_create_task(coro, **kwargs)

        router = SwarmRouter(worker_registry={"BookingWorker": mock_worker})
        signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)

        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            with patch.object(router, "_log_dispatch", side_effect=lambda *a: creation_order.append("log_dispatch")):
                with patch("asyncio.create_task", side_effect=tracking_create_task):
                    task = await router.dispatch(signal)

        assert creation_order == ["create_task", "log_dispatch"], (
            f"Expected create_task before log_dispatch, got: {creation_order}"
        )
        await task


# ---------------------------------------------------------------------------
# Edge case: worker not in registry → returns None without crashing
# ---------------------------------------------------------------------------

class TestEdgeCases:
    """Additional edge-case coverage for dispatch()."""

    @pytest.mark.asyncio
    async def test_missing_worker_returns_none(self, tmp_path):
        """Known intent with route but no matching worker instance → None."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = SwarmRouter(worker_registry={})  # empty — no workers
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            result = await router.dispatch(signal)
        assert result is None

    @pytest.mark.asyncio
    async def test_missing_worker_does_not_write_log(self, tmp_path):
        """No log entry should be written when the worker instance is missing."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = SwarmRouter(worker_registry={})
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)
            await router.dispatch(signal)
        assert not events_file.exists()

    @pytest.mark.asyncio
    async def test_oserror_in_log_dispatch_does_not_crash(self, tmp_path):
        """A write failure in _log_dispatch must be swallowed, not re-raised."""
        events_file = tmp_path / "events.jsonl"
        with patch("core.routing.swarm_router.EVENTS_PATH", events_file):
            router = _make_router(tmp_path)
            signal = _make_signal(IntentType.BOOK_JOB, confidence=0.9)

            with patch("builtins.open", side_effect=OSError("disk full")):
                # Must not raise — observability errors are non-fatal
                task = await router.dispatch(signal)

        # Task should still be created even if log fails
        assert task is not None
        await task

    @pytest.mark.asyncio
    async def test_dispatch_is_coroutine(self):
        """dispatch() must be declared as async (returns a coroutine when called)."""
        router = SwarmRouter(worker_registry={})
        signal = _make_signal(IntentType.UNKNOWN)
        coro = router.dispatch(signal)
        assert asyncio.iscoroutine(coro)
        await coro  # drain to avoid ResourceWarning
