"""
tests/track_a/test_story_5_06.py

Story 5.06 — BookingWorker: Stub + GHL Lead Creation

Black-box tests  (BB1–BB3): validate external behaviour via public API
White-box tests  (WB1–WB3): validate internal implementation details

All external dependencies are fully mocked.
Zero real HTTP calls. Zero real Redis. No SQLite.
"""

import sys
sys.path.insert(0, "/mnt/e/genesis-system")

import asyncio
import json
import pytest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch, call

from core.workers.booking_worker import BookingWorker, GHL_WEBHOOK_URL


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_intent(
    session_id: str = "test-session-506",
    name: str = "George",
    phone: str = "+61412345678",
    location: str = "Cairns",
    service: str = "plumbing",
) -> MagicMock:
    """Factory: creates a duck-typed intent object with extracted_entities."""
    intent = MagicMock()
    intent.session_id = session_id
    intent.extracted_entities = {
        "name": name,
        "phone": phone,
        "location": location,
        "service": service,
    }
    return intent


def _make_http_client(status: int = 200) -> MagicMock:
    """Factory: creates a mock http_client whose post() returns status code."""
    response = MagicMock()
    response.status = status
    client = MagicMock()
    client.post = AsyncMock(return_value=response)
    return client


def _make_redis_client() -> MagicMock:
    """Factory: creates a mock redis_client with a synchronous set() method."""
    client = MagicMock()
    client.set = MagicMock()
    return client


def _make_worker(http_status: int = 200) -> tuple[BookingWorker, MagicMock, MagicMock]:
    """Returns (worker, http_client, redis_client) all mocked."""
    http_client = _make_http_client(http_status)
    redis_client = _make_redis_client()
    worker = BookingWorker(http_client=http_client, redis_client=redis_client)
    return worker, http_client, redis_client


# ---------------------------------------------------------------------------
# BB1: Full entities → POST sent with all fields
# ---------------------------------------------------------------------------


class TestBB1_FullEntitiesPosted:
    """BB1 — execute() with a fully-populated intent POSTs all entity fields."""

    @pytest.mark.asyncio
    async def test_post_is_called_once(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent()
        await worker.execute(intent)
        http_client.post.assert_called_once()

    @pytest.mark.asyncio
    async def test_post_url_is_ghl_webhook(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent()
        await worker.execute(intent)
        args, kwargs = http_client.post.call_args
        assert args[0] == GHL_WEBHOOK_URL

    @pytest.mark.asyncio
    async def test_payload_contains_name(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent(name="George")
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["firstName"] == "George"

    @pytest.mark.asyncio
    async def test_payload_contains_phone(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent(phone="+61412345678")
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["phone"] == "+61412345678"

    @pytest.mark.asyncio
    async def test_payload_contains_location(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent(location="Cairns")
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["location"] == "Cairns"

    @pytest.mark.asyncio
    async def test_payload_contains_service(self):
        worker, http_client, _ = _make_worker()
        intent = _make_intent(service="plumbing")
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["service"] == "plumbing"

    @pytest.mark.asyncio
    async def test_execute_returns_dict(self):
        worker, _, _ = _make_worker()
        intent = _make_intent()
        result = await worker.execute(intent)
        assert isinstance(result, dict)

    @pytest.mark.asyncio
    async def test_execute_never_returns_none(self):
        worker, _, _ = _make_worker()
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result is not None


# ---------------------------------------------------------------------------
# BB2: Missing name entity → payload uses "Unknown" for firstName
# ---------------------------------------------------------------------------


class TestBB2_MissingEntityFallback:
    """BB2 — missing entity fields fall back to 'Unknown', not None or ''."""

    @pytest.mark.asyncio
    async def test_missing_name_uses_unknown(self):
        worker, http_client, _ = _make_worker()
        intent = MagicMock()
        intent.session_id = "session-no-name"
        intent.extracted_entities = {
            "phone": "+61400000000",
            "location": "Brisbane",
            "service": "electrical",
        }
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["firstName"] == "Unknown"

    @pytest.mark.asyncio
    async def test_missing_phone_uses_unknown(self):
        worker, http_client, _ = _make_worker()
        intent = MagicMock()
        intent.session_id = "session-no-phone"
        intent.extracted_entities = {"name": "Alice"}
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        assert kwargs["json"]["phone"] == "Unknown"

    @pytest.mark.asyncio
    async def test_empty_entities_dict_all_unknown(self):
        worker, http_client, _ = _make_worker()
        intent = MagicMock()
        intent.session_id = "session-empty-entities"
        intent.extracted_entities = {}
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        payload = kwargs["json"]
        assert payload["firstName"] == "Unknown"
        assert payload["phone"] == "Unknown"
        assert payload["location"] == "Unknown"
        assert payload["service"] == "Unknown"

    @pytest.mark.asyncio
    async def test_no_entities_attribute_all_unknown(self):
        """Intent missing extracted_entities attribute entirely → all Unknown."""
        worker, http_client, _ = _make_worker()
        intent = MagicMock(spec=[])  # no attributes
        intent.session_id = "session-no-attr"
        await worker.execute(intent)
        _, kwargs = http_client.post.call_args
        payload = kwargs["json"]
        assert payload["firstName"] == "Unknown"
        assert payload["phone"] == "Unknown"

    @pytest.mark.asyncio
    async def test_missing_entities_still_returns_status_dict(self):
        worker, _, _ = _make_worker()
        intent = MagicMock()
        intent.session_id = "session-fallback"
        intent.extracted_entities = {}
        result = await worker.execute(intent)
        assert isinstance(result, dict)
        assert "status" in result


# ---------------------------------------------------------------------------
# BB3: HTTP 200 from webhook → {"status": "ok"} returned
# ---------------------------------------------------------------------------


class TestBB3_Http200ReturnsOk:
    """BB3 — HTTP 200 response from webhook yields {"status": "ok"}."""

    @pytest.mark.asyncio
    async def test_http_200_returns_status_ok(self):
        worker, _, _ = _make_worker(http_status=200)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "ok"

    @pytest.mark.asyncio
    async def test_http_201_also_returns_status_ok(self):
        """201 Created is also a successful 2xx response."""
        worker, _, _ = _make_worker(http_status=201)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "ok"

    @pytest.mark.asyncio
    async def test_http_200_result_contains_lead_id(self):
        worker, _, _ = _make_worker(http_status=200)
        intent = _make_intent(session_id="lead-session-123")
        result = await worker.execute(intent)
        assert "lead_id" in result

    @pytest.mark.asyncio
    async def test_http_200_lead_id_equals_session_id(self):
        worker, _, _ = _make_worker(http_status=200)
        intent = _make_intent(session_id="my-unique-session")
        result = await worker.execute(intent)
        assert result["lead_id"] == "my-unique-session"


# ---------------------------------------------------------------------------
# WB1: _build_lead_payload() always adds source="AIVA_CALL"
# ---------------------------------------------------------------------------


class TestWB1_BuildLeadPayloadSource:
    """WB1 — _build_lead_payload always includes source='AIVA_CALL'."""

    def test_source_is_aiva_call(self):
        worker = BookingWorker()
        intent = _make_intent()
        payload = worker._build_lead_payload(intent)
        assert payload["source"] == "AIVA_CALL"

    def test_source_is_aiva_call_even_when_entities_empty(self):
        worker = BookingWorker()
        intent = MagicMock()
        intent.session_id = "sess-source-check"
        intent.extracted_entities = {}
        payload = worker._build_lead_payload(intent)
        assert payload["source"] == "AIVA_CALL"

    def test_payload_has_session_id(self):
        worker = BookingWorker()
        intent = _make_intent(session_id="session-wb1")
        payload = worker._build_lead_payload(intent)
        assert payload["sessionId"] == "session-wb1"

    def test_payload_has_created_at_iso_format(self):
        worker = BookingWorker()
        intent = _make_intent()
        payload = worker._build_lead_payload(intent)
        assert "createdAt" in payload
        assert "T" in payload["createdAt"]  # ISO 8601 check

    def test_payload_keys_are_ghl_compatible(self):
        """Verify GHL-compatible key names (camelCase)."""
        worker = BookingWorker()
        intent = _make_intent()
        payload = worker._build_lead_payload(intent)
        expected_keys = {"firstName", "phone", "location", "service", "source", "sessionId", "createdAt"}
        assert expected_keys.issubset(set(payload.keys()))


# ---------------------------------------------------------------------------
# WB2: aiva:results:{session_id} written to Redis after POST
# ---------------------------------------------------------------------------


class TestWB2_RedisResultWritten:
    """WB2 — execute() writes the result to Redis at the correct key."""

    @pytest.mark.asyncio
    async def test_redis_set_called_after_post(self):
        worker, _, redis_client = _make_worker(http_status=200)
        intent = _make_intent(session_id="redis-session-001")
        await worker.execute(intent)
        redis_client.set.assert_called_once()

    @pytest.mark.asyncio
    async def test_redis_key_is_aiva_results_session_id(self):
        worker, _, redis_client = _make_worker(http_status=200)
        intent = _make_intent(session_id="my-session-xyz")
        await worker.execute(intent)
        args, _ = redis_client.set.call_args
        assert args[0] == "aiva:results:my-session-xyz"

    @pytest.mark.asyncio
    async def test_redis_value_is_json_string(self):
        worker, _, redis_client = _make_worker(http_status=200)
        intent = _make_intent(session_id="json-check-session")
        await worker.execute(intent)
        args, _ = redis_client.set.call_args
        stored_value = args[1]
        # Must be deserializable JSON
        parsed = json.loads(stored_value)
        assert isinstance(parsed, dict)

    @pytest.mark.asyncio
    async def test_redis_value_contains_status_ok(self):
        worker, _, redis_client = _make_worker(http_status=200)
        intent = _make_intent(session_id="status-session")
        await worker.execute(intent)
        args, _ = redis_client.set.call_args
        parsed = json.loads(args[1])
        assert parsed["status"] == "ok"

    @pytest.mark.asyncio
    async def test_redis_set_called_even_on_http_error(self):
        """Redis must be written regardless of HTTP success/failure."""
        worker, _, redis_client = _make_worker(http_status=500)
        intent = _make_intent(session_id="error-session")
        await worker.execute(intent)
        redis_client.set.assert_called_once()

    @pytest.mark.asyncio
    async def test_no_redis_client_does_not_raise(self):
        """Missing redis_client must be handled silently."""
        http_client = _make_http_client(status=200)
        worker = BookingWorker(http_client=http_client, redis_client=None)
        intent = _make_intent()
        result = await worker.execute(intent)
        # Must still return a valid result dict
        assert result["status"] == "ok"


# ---------------------------------------------------------------------------
# WB3: HTTP error → {"status": "error", "reason": "..."} returned
# ---------------------------------------------------------------------------


class TestWB3_HttpErrorReturnsError:
    """WB3 — non-2xx HTTP response or network exception → status=error dict."""

    @pytest.mark.asyncio
    async def test_http_500_returns_status_error(self):
        worker, _, _ = _make_worker(http_status=500)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "error"

    @pytest.mark.asyncio
    async def test_http_404_returns_status_error(self):
        worker, _, _ = _make_worker(http_status=404)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "error"

    @pytest.mark.asyncio
    async def test_http_error_result_contains_reason(self):
        worker, _, _ = _make_worker(http_status=503)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert "reason" in result
        assert result["reason"]  # non-empty string

    @pytest.mark.asyncio
    async def test_network_exception_returns_error_dict(self):
        """Simulates a connection error — result must still be a valid dict."""
        redis_client = _make_redis_client()
        http_client = MagicMock()
        http_client.post = AsyncMock(side_effect=ConnectionError("network down"))
        worker = BookingWorker(http_client=http_client, redis_client=redis_client)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "error"
        assert "reason" in result

    @pytest.mark.asyncio
    async def test_network_exception_does_not_raise(self):
        """Network errors must be swallowed — never re-raised to the caller."""
        http_client = MagicMock()
        http_client.post = AsyncMock(side_effect=TimeoutError("timeout"))
        worker = BookingWorker(http_client=http_client, redis_client=None)
        intent = _make_intent()
        # Must not raise
        result = await worker.execute(intent)
        assert isinstance(result, dict)

    @pytest.mark.asyncio
    async def test_no_http_client_returns_error(self):
        """No http_client injected → returns error dict, does not crash."""
        worker = BookingWorker(http_client=None, redis_client=None)
        intent = _make_intent()
        result = await worker.execute(intent)
        assert result["status"] == "error"
        assert "reason" in result
