"""Integration tests for auto-table-allocation (tasks 10.3–10.9).

These tests exercise *actual* handler/endpoint code paths—not replicated closure
logic—with mocked external dependencies, verifying allocation behaviour through
the full flow.

Coverage:
 10.3  Saga path → table_id set, table_auto_assigned=True
 10.4  Saga allocation failure → table_id NULL, no saga error
 10.5  Manual create with explicit table_id → allocation skipped
 10.6  Update re-allocation on reserved_at change
 10.7  Manual override (table_auto_assigned=False) preserved on party_size change
 10.8  Public availability endpoint excludes slot when tables full
 10.9  Agent tool returns no_tables_available when tables full
"""

from __future__ import annotations

import importlib
import os
import sys
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC, datetime, time
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, MagicMock, patch

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

_BACKEND_PATH = Path(__file__).resolve().parents[1]
_REPO_ROOT = _BACKEND_PATH.parent
_RESTATE_SERVICES_PATH = _REPO_ROOT / "restate_services"

if str(_BACKEND_PATH) not in sys.path:
    sys.path.insert(0, str(_BACKEND_PATH))
if str(_RESTATE_SERVICES_PATH) not in sys.path:
    sys.path.insert(0, str(_RESTATE_SERVICES_PATH))

from app.models.restaurant import Restaurant  # noqa: E402
from app.models.service_block import ServiceBlock  # noqa: E402
from app.services.service_blocks import ResolvedBlock  # noqa: E402
from app.services.table_allocation import BulkAvailabilityData, TableAllocationResult  # noqa: E402

# ═══════════════════════════════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════════════════════════════

_RID = "rest-1"
_RESERVED_AT = datetime(2026, 6, 15, 19, 0, tzinfo=UTC)
_END_TIME = datetime(2026, 6, 15, 20, 30, tzinfo=UTC)
# Aware-UTC ISO matches the saga's serialised wire format. Manual-create
# paths receive naive-local instead; this constant intentionally uses
# the saga shape so ``ReservationObject.create`` skips the
# restaurant-tz lookup it would otherwise issue.
_RESERVED_AT_ISO = _RESERVED_AT.isoformat()
_END_TIME_ISO = _END_TIME.isoformat()

# ═══════════════════════════════════════════════════════════════════════════════
# Mock infrastructure
# ═══════════════════════════════════════════════════════════════════════════════


def _mk_restaurant(
    *,
    rid: str = _RID,
    name: str = "Test Restaurant",
    phone: str = "555-0100",
    slug: str = "test-restaurant",
    settings: dict[str, Any] | None = None,
) -> Restaurant:
    return Restaurant(
        id=rid,
        name=name,
        phone=phone,
        slug=slug,
        settings=settings or {},
        timezone="Europe/Brussels",
        team_id="team-1",
    )


def _mk_reservation(
    *,
    reservation_id: str = "res-1",
    restaurant_id: str = _RID,
    party_size: int = 4,
    reserved_at: datetime = _RESERVED_AT,
    end_time: datetime = _END_TIME,
    table_id: str | None = None,
    combination_id: str | None = None,
    table_auto_assigned: bool | None = True,
    status: str = "confirmed",
    customer_id: str | None = None,
    guest_name: str = "Test Guest",
    guest_email: str = "test@example.com",
    source: str = "widget",
) -> SimpleNamespace:
    return SimpleNamespace(
        id=reservation_id,
        restaurant_id=restaurant_id,
        party_size=party_size,
        reserved_at=reserved_at,
        end_time=end_time,
        table_id=table_id,
        combination_id=combination_id,
        table_auto_assigned=table_auto_assigned,
        status=status,
        customer_id=customer_id,
        guest_name=guest_name,
        guest_email=guest_email,
        guest_phone=None,
        source=source,
        notes=None,
        completed_at=None,
        served_at=None,
    )


def _mk_block(
    *,
    block_id: str = "block-1",
    start_time: time = time(17, 0),
    end_time: time = time(22, 0),
    block_type: str = "open",
    max_covers: int | None = 50,
    default_duration_minutes: int = 90,
    interval_minutes: int = 15,
) -> ServiceBlock:
    return ServiceBlock(
        id=block_id,
        restaurant_id=_RID,
        day_of_week=0,
        name="Block",
        block_type=block_type,
        start_time=start_time,
        end_time=end_time,
        max_covers=max_covers,
        default_duration_minutes=default_duration_minutes,
        is_active=True,
        display_order=0,
        slot_interval_minutes=interval_minutes,
    )


# ── Result proxies ────────────────────────────────────────────────────────────


class _ResultProxy:
    """Mock SQLAlchemy result with scalar_one_or_none / scalars().all()."""

    def __init__(
        self,
        *,
        scalar: Any = None,
        scalars_list: list[Any] | None = None,
    ) -> None:
        self._scalar = scalar
        self._scalars_list = scalars_list

    def scalar_one_or_none(self) -> Any:
        return self._scalar

    def scalar_one(self) -> Any:
        if self._scalar is None:
            msg = "No result found"
            raise ValueError(msg)
        return self._scalar

    def scalars(self) -> _ScalarsProxy:
        return _ScalarsProxy(self._scalars_list or [])


class _ScalarsProxy:
    def __init__(self, items: list[Any]) -> None:
        self._items = items

    def all(self) -> list[Any]:
        return self._items

    def __iter__(self):
        return iter(self._items)


# ── Sequenced mock session ────────────────────────────────────────────────────


class _SeqSession:
    """Async DB session returning pre-programmed results in execute order."""

    def __init__(self, results: list[_ResultProxy]) -> None:
        self._results = list(results)
        self._idx = 0
        self.added: list[object] = []
        self.committed: bool = False

    async def execute(self, _stmt: object, _params: object = None) -> _ResultProxy:
        if self._idx < len(self._results):
            r = self._results[self._idx]
            self._idx += 1
            return r
        return _ResultProxy()

    async def commit(self) -> None:
        self.committed = True

    def add(self, obj: object) -> None:
        self.added.append(obj)

    async def refresh(self, _obj: object) -> None:
        pass

    async def __aenter__(self) -> _SeqSession:
        return self

    async def __aexit__(self, *_args: object) -> bool:
        return False


def _db_factory(sessions: list[_SeqSession]):
    """Build a ``get_db_session`` replacement yielding sessions in order."""
    idx = [0]

    @asynccontextmanager
    async def _factory(*_args, **_kwargs) -> AsyncGenerator[_SeqSession, None]:
        session = sessions[idx[0]]
        idx[0] += 1
        yield session

    return _factory


# ── Mock Restate context ──────────────────────────────────────────────────────


class _MockCtx:
    """Mock Restate ObjectContext for handler / saga tests.

    When *call_through_handlers* is provided, ``object_call`` will actually
    execute the named handler functions (via a sub-context) instead of returning
    a stub response.  Steps executed inside the sub-context are appended to the
    outer ``run_steps`` so assertions work transparently.
    """

    def __init__(
        self,
        key: str = "res-1",
        *,
        call_through_handlers: set[str] | None = None,
    ) -> None:
        self._key = key
        self.run_steps: list[str] = []
        self.object_calls: list[tuple[str, str, dict[str, Any]]] = []
        self._call_through = call_through_handlers or set()

    def key(self) -> str:
        return self._key

    def uuid(self) -> str:
        return "test-uuid-1234"

    async def run(self, step_name: str, fn: Any) -> object:
        self.run_steps.append(step_name)
        return await fn()

    async def object_call(
        self,
        handler: Any,
        *,
        key: str,
        arg: dict[str, Any],
    ) -> dict[str, Any]:
        name = getattr(handler, "__name__", str(handler))
        self.object_calls.append((name, key, arg))
        if name in self._call_through:
            # Execute the real handler with a fresh sub-context
            sub_ctx = _MockCtx(key=key)
            result = await handler(sub_ctx, arg)
            self.run_steps.extend(sub_ctx.run_steps)
            return result
        return {
            "id": key,
            "status": arg.get("status", "confirmed"),
            "restaurant_id": _RID,
            "changed": True,
        }

    async def service_call(self, _handler: Any, *, arg: Any) -> dict[str, str]:
        return {"status": "ok"}

    def service_send(self, _handler: Any, *, arg: Any) -> None:
        pass


# ═══════════════════════════════════════════════════════════════════════════════
# 10.3: Saga path — table auto-allocated on success
# ═══════════════════════════════════════════════════════════════════════════════


class TestSagaPathAllocatesTable(IsolatedAsyncioTestCase):
    """Verify table_id is set and table_auto_assigned=True after saga completes.

    The saga delegates to objects.reservation.create via ctx.object_call, and
    allocation happens inside that handler.  We enable call-through so the
    handler actually runs, providing separate mock sessions for each module.
    """

    async def test_saga_allocates_table(self) -> None:
        restaurant = _mk_restaurant()
        reservation = _mk_reservation(
            table_id=None,
            combination_id=None,
            table_auto_assigned=None,
        )
        alloc = TableAllocationResult(table_id="t1", combination_id=None, capacity=4)
        block = _mk_block()

        # -- Saga sessions (reservation_saga.get_db_session) --
        # s1: _fetch_and_check — restaurant + empty bookings
        s1 = _SeqSession(
            [
                _ResultProxy(scalar=restaurant),
                _ResultProxy(scalars_list=[]),
            ]
        )
        # s_cust: _resolve_customer — no existing match (None) triggers insert
        s_cust = _SeqSession([_ResultProxy(scalar=None)])
        # s2: _send_confirmation — reservation + restaurant
        s2 = _SeqSession(
            [
                _ResultProxy(scalar=reservation),
                _ResultProxy(scalar=restaurant),
            ]
        )

        # -- Handler sessions (objects.reservation.get_db_session) --
        # h1: _insert — no queries (saga_validated=True, no table_id)
        h1 = _SeqSession([])
        # h2: _emit_notification — create_notification is mocked
        h2 = _SeqSession([])
        # h3: _allocate_table — reads reservation by id
        h3 = _SeqSession([_ResultProxy(scalar=reservation)])

        ctx = _MockCtx("res-1", call_through_handlers={"create"})
        data = {
            "restaurant_id": _RID,
            "reserved_at": _RESERVED_AT_ISO,
            "party_size": 4,
            "guest_name": "Test Guest",
            "guest_email": "test@example.com",
        }

        with (
            patch("reservation_saga.get_tenant_db_session", _db_factory([s1, s_cust, s2])),
            patch("objects.reservation.get_db_session", _db_factory([h1, h2, h3])),
            patch("objects.reservation._guard_table_conflict", AsyncMock()),
            patch("app.services.notifications.create_notification", AsyncMock()),
            patch(
                "app.services.service_blocks.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.service_blocks.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.services.service_blocks.snap_to_interval",
                return_value=_RESERVED_AT.time(),
            ),
            patch("app.services.service_blocks.calculate_end_time", return_value=_END_TIME),
            patch(
                "app.services.table_allocation.find_best_table",
                AsyncMock(return_value=alloc),
            ),
            patch("app.email.scaleway.ScalewayEmailClient") as mock_email_cls,
            patch("event_publisher.publish_event", new_callable=AsyncMock),
            patch("logfire.info"),
            patch("logfire.warning"),
        ):
            mock_email_cls.return_value.send_reservation_confirmation = AsyncMock()

            create_reservation = importlib.import_module("reservation_saga").create_reservation

            result = await create_reservation(ctx, data)

        assert result["status"] == "confirmed"
        assert reservation.table_id == "t1"
        assert reservation.combination_id is None
        assert reservation.table_auto_assigned is True
        assert "allocate_table" in ctx.run_steps


# ═══════════════════════════════════════════════════════════════════════════════
# 10.4: Saga allocation failure — reservation confirmed, table_id NULL
# ═══════════════════════════════════════════════════════════════════════════════


class TestSagaAllocationFailureGraceful(IsolatedAsyncioTestCase):
    """Verify reservation is confirmed with table_id=NULL when no table available.

    Same call-through approach as 10.3 — the handler's _allocate_table runs but
    find_best_table returns None, so allocation is skipped gracefully.
    """

    async def test_saga_succeeds_without_table(self) -> None:
        restaurant = _mk_restaurant()
        reservation = _mk_reservation(
            table_id=None,
            combination_id=None,
            table_auto_assigned=None,
        )
        block = _mk_block()

        # -- Saga sessions --
        s1 = _SeqSession(
            [
                _ResultProxy(scalar=restaurant),
                _ResultProxy(scalars_list=[]),
            ]
        )
        # _resolve_customer — no existing match triggers the insert path
        s_cust = _SeqSession([_ResultProxy(scalar=None)])
        s2 = _SeqSession(
            [
                _ResultProxy(scalar=reservation),
                _ResultProxy(scalar=restaurant),
            ]
        )

        # -- Handler sessions --
        h1 = _SeqSession([])  # _insert
        h2 = _SeqSession([])  # _emit_notification (create_notification mocked)
        # h3: _allocate_table — reads reservation, then find_best_table→None
        h3 = _SeqSession([_ResultProxy(scalar=reservation)])

        ctx = _MockCtx("res-1", call_through_handlers={"create"})
        data = {
            "restaurant_id": _RID,
            "reserved_at": _RESERVED_AT_ISO,
            "party_size": 4,
            "guest_name": "Test Guest",
            "guest_email": "test@example.com",
        }

        with (
            patch("reservation_saga.get_tenant_db_session", _db_factory([s1, s_cust, s2])),
            patch("objects.reservation.get_db_session", _db_factory([h1, h2, h3])),
            patch("objects.reservation._guard_table_conflict", AsyncMock()),
            patch("app.services.notifications.create_notification", AsyncMock()),
            patch(
                "app.services.service_blocks.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.service_blocks.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.services.service_blocks.snap_to_interval",
                return_value=_RESERVED_AT.time(),
            ),
            patch("app.services.service_blocks.calculate_end_time", return_value=_END_TIME),
            patch(
                "app.services.table_allocation.find_best_table",
                AsyncMock(return_value=None),  # No table available
            ),
            patch("app.email.scaleway.ScalewayEmailClient") as mock_email_cls,
            patch("event_publisher.publish_event", new_callable=AsyncMock),
            patch("logfire.info"),
            patch("logfire.warning"),
        ):
            mock_email_cls.return_value.send_reservation_confirmation = AsyncMock()

            create_reservation = importlib.import_module("reservation_saga").create_reservation

            result = await create_reservation(ctx, data)

        # Saga still completes successfully
        assert result["status"] == "confirmed"
        # No table allocated
        assert reservation.table_id is None
        assert reservation.combination_id is None
        # No saga error — allocate step ran
        assert "allocate_table" in ctx.run_steps


# ═══════════════════════════════════════════════════════════════════════════════
# 10.5: Manual create with explicit table_id — allocation skipped
# ═══════════════════════════════════════════════════════════════════════════════


class TestManualCreateSkipsAllocation(IsolatedAsyncioTestCase):
    """Verify explicit table_id → allocation skipped, table_auto_assigned=False."""

    async def test_create_with_explicit_table_marks_manual(self) -> None:
        """Call ReservationObject.create with table_id; verify
        _allocate_table sets table_auto_assigned=False and does NOT call
        find_best_table."""
        block = _mk_block()

        # The reservation as it appears in the DB after _insert
        reservation = _mk_reservation(
            table_id="t-manual",
            combination_id=None,
            table_auto_assigned=None,
            status="pending",
        )

        # Session for _insert: table validation → table found, bookings → empty
        s_insert = _SeqSession(
            [
                _ResultProxy(scalar="t-manual"),  # FloorTable.id query (scalar)
                _ResultProxy(scalars_list=[]),  # booking count
            ]
        )

        # Session for _allocate_table: fetch reservation
        s_alloc = _SeqSession(
            [
                _ResultProxy(scalar=reservation),
            ]
        )

        ctx = _MockCtx("res-1")
        data: dict[str, Any] = {
            "restaurant_id": _RID,
            "guest_name": "Test Guest",
            "party_size": 4,
            "reserved_at": _RESERVED_AT_ISO,
            "table_id": "t-manual",
        }

        find_mock = AsyncMock(return_value=None)

        with (
            patch(
                "objects.reservation.get_db_session",
                _db_factory([s_insert, s_alloc]),
            ),
            patch(
                "app.services.service_blocks.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.service_blocks.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.services.service_blocks.snap_to_interval",
                return_value=_RESERVED_AT.time(),
            ),
            patch(
                "app.services.service_blocks.calculate_end_time",
                return_value=_END_TIME,
            ),
            patch("app.services.table_allocation.find_best_table", find_mock),
            patch(
                "objects.reservation._guard_table_conflict",
                new_callable=AsyncMock,
            ),
            patch(
                "objects.reservation._create_in_app_notification",
                new_callable=AsyncMock,
            ),
            patch("event_publisher.publish_event", new_callable=AsyncMock),
        ):
            create = importlib.import_module("objects.reservation").create

            await create(ctx, data)

        # Allocation closure detected caller-provided table → manual
        assert reservation.table_auto_assigned is False
        # find_best_table was never called
        find_mock.assert_not_called()
        # The allocate step ran (it just short-circuited)
        assert "allocate_table" in ctx.run_steps


# ═══════════════════════════════════════════════════════════════════════════════
# 10.6: Update re-allocation on reserved_at change
# ═══════════════════════════════════════════════════════════════════════════════


class TestUpdateReallocation(IsolatedAsyncioTestCase):
    """Verify changing reserved_at on auto-assigned reservation triggers
    re-allocation and assigns a new table."""

    async def test_time_change_triggers_reallocation(self) -> None:
        new_time = datetime(2026, 3, 16, 20, 0)
        block = _mk_block()

        reservation = _mk_reservation(
            table_id="t-old",
            combination_id=None,
            table_auto_assigned=True,
            reserved_at=_RESERVED_AT,
            end_time=_END_TIME,
        )
        new_alloc = TableAllocationResult(table_id="t-new", combination_id=None, capacity=4)

        # Update session: fetch reservation, then booking count
        s = _SeqSession(
            [
                _ResultProxy(scalar=reservation),
                _ResultProxy(scalars_list=[]),  # bookings
            ]
        )

        ctx = _MockCtx("res-1")
        data: dict[str, Any] = {"reserved_at": new_time.isoformat()}

        with (
            patch("objects.reservation.get_db_session", _db_factory([s])),
            patch(
                "app.services.service_blocks.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.service_blocks.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.services.service_blocks.snap_to_interval",
                return_value=new_time.time(),
            ),
            patch(
                "app.services.table_allocation.find_best_table",
                AsyncMock(return_value=new_alloc),
            ),
            patch(
                "objects.reservation._guard_table_conflict",
                new_callable=AsyncMock,
            ),
            patch("event_publisher.publish_event", new_callable=AsyncMock),
        ):
            update = importlib.import_module("objects.reservation").update

            result = await update(ctx, data)

        assert result["id"] == "res-1"
        assert reservation.table_id == "t-new"
        assert reservation.combination_id is None
        assert reservation.table_auto_assigned is True
        assert s.committed


# ═══════════════════════════════════════════════════════════════════════════════
# 10.7: Manual override preserved on party_size change
# ═══════════════════════════════════════════════════════════════════════════════


class TestManualOverridePreservedOnUpdate(IsolatedAsyncioTestCase):
    """Verify table_auto_assigned=False blocks re-allocation even when
    party_size changes."""

    async def test_manual_table_unchanged_on_party_size_change(self) -> None:
        block = _mk_block()

        reservation = _mk_reservation(
            table_id="t-manual",
            combination_id=None,
            table_auto_assigned=False,
            party_size=4,
        )

        s = _SeqSession(
            [
                _ResultProxy(scalar=reservation),
                _ResultProxy(scalars_list=[]),  # bookings
            ]
        )

        ctx = _MockCtx("res-1")
        data: dict[str, Any] = {"party_size": 8}
        find_mock = AsyncMock(
            return_value=TableAllocationResult(
                table_id="t-should-not-use",
                combination_id=None,
                capacity=10,
            ),
        )

        with (
            patch("objects.reservation.get_db_session", _db_factory([s])),
            patch(
                "app.services.service_blocks.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.service_blocks.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.services.service_blocks.snap_to_interval",
                return_value=_RESERVED_AT.time(),
            ),
            patch("app.services.table_allocation.find_best_table", find_mock),
            patch(
                "objects.reservation._guard_table_conflict",
                new_callable=AsyncMock,
            ),
            patch("event_publisher.publish_event", new_callable=AsyncMock),
        ):
            update = importlib.import_module("objects.reservation").update

            result = await update(ctx, data)

        assert result["id"] == "res-1"
        # Table preserved — re-allocation NOT triggered
        assert reservation.table_id == "t-manual"
        assert reservation.table_auto_assigned is False
        # find_best_table was never called
        find_mock.assert_not_called()


# ═══════════════════════════════════════════════════════════════════════════════
# 10.8: Public availability endpoint excludes slot when tables full
# ═══════════════════════════════════════════════════════════════════════════════


_EMPTY_AVAILABILITY_DATA = BulkAvailabilityData(
    table_candidates=[],
    ranked_combos=[],
    all_combos=[],
    occupied={},
    all_reservations=[],
)


class TestPublicAvailabilityExcludesOccupiedSlots(IsolatedAsyncioTestCase):
    """Verify slot is excluded when all tables are occupied despite having
    cover room."""

    async def test_slot_excluded_when_tables_full(self) -> None:
        restaurant = _mk_restaurant()
        block = _mk_block(
            start_time=time(17, 0),
            end_time=time(22, 0),
            max_covers=50,
            default_duration_minutes=90,
            interval_minutes=15,
        )

        # Mock session: restaurant lookup + booking count + combo query
        session = AsyncMock()
        _call_count = [0]

        async def _execute(stmt: object, _params: object = None) -> object:
            _call_count[0] += 1
            # Booking count query → empty (covers available)
            result = MagicMock()
            result.scalars.return_value.all.return_value = []
            # Combo query → empty
            return result

        session.execute = _execute

        with (
            patch(
                "app.routers.public._get_restaurant_by_slug",
                AsyncMock(return_value=restaurant),
            ),
            patch(
                "app.services.availability.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.services.availability.generate_available_slots",
                return_value=[time(19, 0)],  # One candidate slot
            ),
            patch(
                "app.services.availability.preload_slot_availability",
                AsyncMock(return_value=_EMPTY_AVAILABILITY_DATA),
            ),
            patch(
                "app.services.availability.slot_has_availability",
                return_value=False,  # All tables occupied
            ),
        ):
            from app.routers.public import get_availability

            result = await get_availability(
                slug="test-restaurant",
                date="2026-03-15",
                party_size=4,
                session=session,
            )

        # Slot excluded despite having cover room
        assert result["available_slots"] == []
        assert result["combo_options"] == []


# ═══════════════════════════════════════════════════════════════════════════════
# 10.9: Agent availability tool — no_tables_available
# ═══════════════════════════════════════════════════════════════════════════════


class TestAgentToolNoTablesAvailable(IsolatedAsyncioTestCase):
    """Verify agent tool returns reason='no_tables_available' when tables full."""

    async def test_no_tables_available_reason(self) -> None:
        restaurant = _mk_restaurant(
            settings={
                "min_advance_hours": 0,
                "max_advance_days": 365,
                "max_party_size": 20,
            }
        )
        block = _mk_block()

        # Mock session: restaurant query + booking count query
        session = AsyncMock()
        _call_count = [0]

        async def _execute(stmt: object, _params: object = None) -> object:
            _call_count[0] += 1
            if _call_count[0] == 1:
                # Restaurant query
                result = MagicMock()
                result.scalar_one_or_none.return_value = restaurant
                return result
            # Booking count query → empty (covers available)
            result = MagicMock()
            result.scalars.return_value.all.return_value = []
            return result

        session.execute = _execute

        # Mock RunContext with deps
        mock_deps = SimpleNamespace(
            session=session,
            restaurant_id=_RID,
            http_client=AsyncMock(),
        )
        mock_ctx = MagicMock()
        mock_ctx.deps = mock_deps

        with (
            patch(
                "app.agents.tools.reservation.resolve_service_blocks",
                AsyncMock(return_value=[ResolvedBlock(block=block)]),
            ),
            patch(
                "app.agents.tools.reservation.find_block_for_time",
                return_value=ResolvedBlock(block=block),
            ),
            patch(
                "app.agents.tools.reservation.snap_to_interval",
                return_value=_RESERVED_AT.time(),
            ),
            patch(
                "app.agents.tools.reservation.has_any_available_table",
                AsyncMock(return_value=False),
            ),
        ):
            from app.agents.tools.reservation import check_availability_impl

            result = await check_availability_impl(
                mock_ctx,
                date="2026-06-15",
                time="19:00",
                party_size=4,
            )

        assert result["available"] is False
        assert result["reason"] == "no_tables_available"
