"""Tests for min_advance_hours enforcement across all booking paths.

Validates that:
1. get_availability filters out past and too-soon slots
2. get_calendar_status marks past dates as closed
3. Reservation saga rejects past and too-soon bookings
"""

from __future__ import annotations

import importlib
import os
import sys
from datetime import datetime, time, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, MagicMock, patch
from zoneinfo import ZoneInfo

from app.services.table_allocation import BulkAvailabilityData

_ = os.environ.setdefault("NEON_DATABASE_URL", "postgresql://user:pass@localhost:5432/testdb")

_REPO_ROOT = Path(__file__).resolve().parents[2]
_RESTATE_SERVICES_PATH = _REPO_ROOT / "restate_services"
if str(_RESTATE_SERVICES_PATH) not in sys.path:
    sys.path.insert(0, str(_RESTATE_SERVICES_PATH))

_RID = "restaurant-1"
_TZ = "Europe/Brussels"
_EMPTY_AVAILABILITY_DATA = BulkAvailabilityData(
    table_candidates=[],
    ranked_combos=[],
    all_combos=[],
    occupied={},
    all_reservations=[],
)


def _mk_restaurant(
    *,
    min_advance_hours: int = 2,
    max_advance_days: int = 90,
    timezone: str = _TZ,
) -> SimpleNamespace:
    return SimpleNamespace(
        id=_RID,
        name="Test Restaurant",
        phone="555-0100",
        slug="test-restaurant",
        timezone=timezone,
        settings={
            "min_advance_hours": min_advance_hours,
            "max_advance_days": max_advance_days,
            "max_party_size": 20,
        },
    )


def _mk_block(
    *,
    start_time: time = time(17, 0),
    end_time: time = time(22, 0),
    max_covers: int | None = 50,
    default_duration_minutes: int = 90,
    interval_minutes: int = 15,
) -> SimpleNamespace:
    block = SimpleNamespace(
        id="block-1",
        restaurant_id=_RID,
        block_type="open",
        day_of_week=0,
        start_time=start_time,
        end_time=end_time,
        max_covers=max_covers,
        default_duration_minutes=default_duration_minutes,
        interval_minutes=interval_minutes,
        is_active=True,
    )
    # Wrap in ResolvedBlock-like shape (resolve_service_blocks returns this)
    return SimpleNamespace(block=block, open_zone_ids=None)


# ═══════════════════════════════════════════════════════════════════════════════
# 1. get_availability — filters past and too-soon slots
# ═══════════════════════════════════════════════════════════════════════════════


class TestGetAvailabilityAdvanceFilter(IsolatedAsyncioTestCase):
    """Slots within min_advance_hours of now must be excluded."""

    async def test_past_date_returns_empty(self) -> None:
        """Requesting slots for yesterday returns nothing."""
        restaurant = _mk_restaurant(min_advance_hours=1)
        session = AsyncMock()

        with patch(
            "app.routers.public._get_restaurant_by_slug",
            AsyncMock(return_value=restaurant),
        ):
            from app.routers.public import get_availability

            yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
            result = await get_availability(
                slug="test-restaurant",
                date=yesterday,
                party_size=2,
                session=session,
            )

        self.assertEqual(result["available_slots"], [])
        self.assertEqual(result["combo_options"], [])

    async def test_too_soon_slots_filtered(self) -> None:
        """Slots within min_advance_hours from now are excluded.

        Uses a fixed 'now' (14:00 CET) so the test is deterministic regardless
        of when CI runs.  Without freezing, runs after ~19:00 CET would
        produce an ok_slot past midnight that falls outside the block window.
        """
        restaurant = _mk_restaurant(min_advance_hours=2)
        block = _mk_block(start_time=time(10, 0), end_time=time(22, 0))

        tz = ZoneInfo(_TZ)
        frozen_now = datetime(2026, 3, 10, 14, 0, 0, tzinfo=tz)
        too_soon_slot = time(14, 30)  # +30 min — inside 2 h advance window
        ok_slot = time(17, 0)  # +3 h   — outside 2 h advance window
        today_str = "2026-03-10"

        session = AsyncMock()
        session.execute = AsyncMock(
            return_value=MagicMock(
                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
            )
        )

        # Build a datetime stand-in that returns our frozen value from now()
        # but delegates everything else to the real class.
        _real_dt = datetime

        class _FrozenDatetime(_real_dt):
            @classmethod
            def now(cls, tz=None):  # noqa: ANN001, ANN201, N805
                return frozen_now.replace(tzinfo=None)

        with (
            patch(
                "app.routers.public._get_restaurant_by_slug",
                AsyncMock(return_value=restaurant),
            ),
            patch(
                "app.services.availability.resolve_service_blocks",
                AsyncMock(return_value=[block]),
            ),
            patch(
                "app.services.availability.generate_available_slots",
                return_value=[too_soon_slot, ok_slot],
            ),
            patch(
                "app.services.availability.preload_slot_availability",
                AsyncMock(return_value=_EMPTY_AVAILABILITY_DATA),
            ),
            patch("app.services.availability.slot_has_availability", return_value=True),
            patch("app.services.availability.datetime", _FrozenDatetime),
        ):
            from app.routers.public import get_availability

            result = await get_availability(
                slug="test-restaurant",
                date=today_str,
                party_size=2,
                session=session,
            )

        # Only the slot outside the advance window should appear
        self.assertNotIn(too_soon_slot.strftime("%H:%M"), result["available_slots"])
        self.assertIn(ok_slot.strftime("%H:%M"), result["available_slots"])

    async def test_far_future_date_returns_empty(self) -> None:
        """Requesting slots beyond max_advance_days returns nothing."""
        restaurant = _mk_restaurant(max_advance_days=30)
        session = AsyncMock()

        with patch(
            "app.routers.public._get_restaurant_by_slug",
            AsyncMock(return_value=restaurant),
        ):
            from app.routers.public import get_availability

            far_future = (datetime.now() + timedelta(days=60)).strftime("%Y-%m-%d")
            result = await get_availability(
                slug="test-restaurant",
                date=far_future,
                party_size=2,
                session=session,
            )

        self.assertEqual(result["available_slots"], [])

    async def test_zero_advance_hours_allows_near_slots(self) -> None:
        """With min_advance_hours=0, current-time slots are allowed."""
        restaurant = _mk_restaurant(min_advance_hours=0)
        block = _mk_block(start_time=time(0, 0), end_time=time(23, 59))

        tz = ZoneInfo(_TZ)
        now_local = datetime.now(tz).replace(tzinfo=None)
        # A slot 5 minutes from now should be allowed
        near_slot = (now_local + timedelta(minutes=5)).replace(second=0, microsecond=0).time()
        today_str = now_local.strftime("%Y-%m-%d")

        session = AsyncMock()
        session.execute = AsyncMock(
            return_value=MagicMock(
                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
            )
        )

        with (
            patch(
                "app.routers.public._get_restaurant_by_slug",
                AsyncMock(return_value=restaurant),
            ),
            patch(
                "app.services.availability.resolve_service_blocks",
                AsyncMock(return_value=[block]),
            ),
            patch(
                "app.services.availability.generate_available_slots",
                return_value=[near_slot],
            ),
            patch(
                "app.services.availability.preload_slot_availability",
                AsyncMock(return_value=_EMPTY_AVAILABILITY_DATA),
            ),
            patch("app.services.availability.slot_has_availability", return_value=True),
        ):
            from app.routers.public import get_availability

            result = await get_availability(
                slug="test-restaurant",
                date=today_str,
                party_size=2,
                session=session,
            )

        self.assertIn(near_slot.strftime("%H:%M"), result["available_slots"])


# ═══════════════════════════════════════════════════════════════════════════════
# 2. get_calendar_status — past dates and advance window
# ═══════════════════════════════════════════════════════════════════════════════


class TestCalendarStatusAdvanceFilter(IsolatedAsyncioTestCase):
    """Past dates must be marked as closed in calendar-status."""

    async def test_past_dates_marked_closed(self) -> None:
        restaurant = _mk_restaurant(min_advance_hours=1)
        session = AsyncMock()

        yesterday = (datetime.now() - timedelta(days=1)).date()
        day_before = (datetime.now() - timedelta(days=2)).date()

        # Mock all bulk queries to return empty
        call_count = [0]

        async def _execute(stmt: object, _params: object = None) -> object:
            call_count[0] += 1
            result = MagicMock()
            result.scalars.return_value.all.return_value = []
            result.all.return_value = []
            return result

        session.execute = _execute

        with patch(
            "app.routers.public._get_restaurant_by_slug",
            AsyncMock(return_value=restaurant),
        ):
            from app.routers.public import get_calendar_status

            result = await get_calendar_status(
                slug="test-restaurant",
                start_date=day_before.isoformat(),
                end_date=yesterday.isoformat(),
                party_size=2,
                session=session,
            )

        days = result["days"]
        self.assertEqual(days[day_before.isoformat()], "closed")
        self.assertEqual(days[yesterday.isoformat()], "closed")


# ═══════════════════════════════════════════════════════════════════════════════
# 3. Reservation saga — rejects past and too-soon bookings
# ═══════════════════════════════════════════════════════════════════════════════


class TestReservationSagaAdvanceValidation(IsolatedAsyncioTestCase):
    """Saga must reject bookings in the past or within advance window."""

    async def test_rejects_past_booking(self) -> None:
        """Booking in the past raises TerminalError."""
        from restate import TerminalError

        reservation_saga = importlib.import_module("reservation_saga")

        restaurant = _mk_restaurant(min_advance_hours=1)
        past_time = (datetime.now() - timedelta(hours=2)).replace(second=0, microsecond=0)

        # Build the _fetch_and_check callable by calling create_reservation
        # We need to test the inner function, so we'll test through the saga
        # by mocking the Restate context and DB session

        # Create a mock Restate context
        ctx = MagicMock()
        ctx.key.return_value = "res-test-1"

        data = {
            "restaurant_id": _RID,
            "reserved_at": past_time.isoformat(),
            "party_size": 2,
            "guest_name": "Test",
        }

        # Mock the ctx.run to actually execute the callback
        async def _run_callback(name: str, callback: Any) -> dict:
            return await callback()

        ctx.run = _run_callback

        # Mock DB session
        mock_session = AsyncMock()

        async def _execute(stmt: object, _params: object = None) -> object:
            result = MagicMock()
            result.scalar_one_or_none.return_value = restaurant
            return result

        mock_session.execute = _execute

        with patch(
            "reservation_saga.get_tenant_db_session",
            return_value=AsyncMock(
                __aenter__=AsyncMock(return_value=mock_session),
                __aexit__=AsyncMock(return_value=False),
            ),
        ):
            with self.assertRaises(TerminalError) as cm:
                await reservation_saga.create_reservation(ctx, data)
            self.assertIn("past", str(cm.exception).lower())

    async def test_rejects_too_soon_booking(self) -> None:
        """Booking within min_advance_hours raises TerminalError."""
        from restate import TerminalError

        reservation_saga = importlib.import_module("reservation_saga")

        restaurant = _mk_restaurant(min_advance_hours=4)
        # 2 hours from now in the restaurant's timezone — within the 4-hour advance window
        # but definitely in the future regardless of where tests run
        tz = ZoneInfo(_TZ)
        now_local = datetime.now(tz).replace(tzinfo=None)
        too_soon = (now_local + timedelta(hours=2)).replace(second=0, microsecond=0)

        ctx = MagicMock()
        ctx.key.return_value = "res-test-2"

        data = {
            "restaurant_id": _RID,
            "reserved_at": too_soon.isoformat(),
            "party_size": 2,
            "guest_name": "Test",
        }

        async def _run_callback(name: str, callback: Any) -> dict:
            return await callback()

        ctx.run = _run_callback

        mock_session = AsyncMock()

        async def _execute(stmt: object, _params: object = None) -> object:
            result = MagicMock()
            result.scalar_one_or_none.return_value = restaurant
            return result

        mock_session.execute = _execute

        with patch(
            "reservation_saga.get_tenant_db_session",
            return_value=AsyncMock(
                __aenter__=AsyncMock(return_value=mock_session),
                __aexit__=AsyncMock(return_value=False),
            ),
        ):
            with self.assertRaises(TerminalError) as cm:
                await reservation_saga.create_reservation(ctx, data)
            self.assertIn("advance", str(cm.exception).lower())


# ═══════════════════════════════════════════════════════════════════════════════
# 4. PublicRestaurantRead includes min_advance_hours
# ═══════════════════════════════════════════════════════════════════════════════


class TestPublicRestaurantExposesAdvanceHours(IsolatedAsyncioTestCase):
    """Public restaurant endpoint must expose min_advance_hours."""

    async def test_min_advance_hours_in_response(self) -> None:
        restaurant = _mk_restaurant(min_advance_hours=3)
        session = AsyncMock()

        with patch(
            "app.routers.public._get_restaurant_by_slug",
            AsyncMock(return_value=restaurant),
        ):
            from app.routers.public import get_public_restaurant

            result = await get_public_restaurant(
                slug="test-restaurant",
                session=session,
            )

        self.assertEqual(result.min_advance_hours, 3)

    async def test_min_advance_hours_defaults_to_1(self) -> None:
        restaurant = SimpleNamespace(
            id=_RID,
            name="Test",
            slug="test",
            phone=None,
            timezone=_TZ,
            settings={},
        )
        session = AsyncMock()

        with patch(
            "app.routers.public._get_restaurant_by_slug",
            AsyncMock(return_value=restaurant),
        ):
            from app.routers.public import get_public_restaurant

            result = await get_public_restaurant(
                slug="test",
                session=session,
            )

        self.assertEqual(result.min_advance_hours, 1)
