"""Tests for reservation agent tool functions."""

from __future__ import annotations

from datetime import UTC, datetime, time, timedelta
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic_ai import ModelRetry

from app.agents.tools.reservation import (
    cancel_reservation_impl,
    check_availability_impl,
    create_reservation_impl,
    find_available_slots_impl,
    find_reservation_impl,
    get_restaurant_info_impl,
    modify_reservation_impl,
)

pytestmark = pytest.mark.anyio

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

TOOLS_MOD = "app.agents.tools.reservation"


def _restaurant_with_settings(settings: dict | None = None) -> MagicMock:
    r = MagicMock()
    r.settings = settings or {}
    r.name = "Test Restaurant"
    r.phone = "+31612345678"
    r.timezone = "Europe/Amsterdam"
    return r


def _scalar_result(value):
    """Wraps a value so result.scalar_one_or_none() returns it."""
    res = MagicMock()
    res.scalar_one_or_none.return_value = value
    return res


def _scalars_result(values: list):
    """Wraps a list so result.scalars().all() returns it."""
    res = MagicMock()
    scalars_mock = MagicMock()
    scalars_mock.all.return_value = values
    res.scalars.return_value = scalars_mock
    return res


def _service_block(
    *,
    start_h: int = 17,
    start_m: int = 0,
    end_h: int = 22,
    end_m: int = 0,
    max_covers: int | None = 40,
    default_duration: int = 90,
    day_of_week: int = 0,
    block_type: str = "open",
    block_id: str = "block-1",
):
    b = MagicMock()
    b.id = block_id
    b.start_time = time(start_h, start_m)
    b.end_time = time(end_h, end_m)
    b.max_covers = max_covers
    b.default_duration_minutes = default_duration
    b.day_of_week = day_of_week
    b.block_type = block_type
    b.is_active = True
    return b


def _matching_block(block):
    """Mimics a ResolvedBlock namedtuple with .block attribute."""
    m = MagicMock()
    m.block = block
    return m


def _reservation(
    *,
    id: str = "res-1",
    status: str = "confirmed",
    party_size: int = 2,
    guest_name: str = "Jan Jansen",
    guest_email: str = "jan@example.com",
    reserved_at: datetime | None = None,
    customer_id: str | None = "cust-1",
    restaurant_id: str = "test-restaurant-id",
):
    r = MagicMock()
    r.id = id
    r.status = status
    r.party_size = party_size
    r.guest_name = guest_name
    r.guest_email = guest_email
    r.reserved_at = reserved_at or datetime(2026, 6, 15, 19, 0)
    r.customer_id = customer_id
    r.restaurant_id = restaurant_id
    return r


# ===================================================================
# check_availability_impl
# ===================================================================


class TestCheckAvailability:
    async def test_invalid_date_format_raises_model_retry(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            pytest.raises(ModelRetry, match="Ongeldige datum"),
        ):
            await check_availability_impl(fake_ctx, "not-a-date", "19:00", 2)

    async def test_invalid_time_format_raises_model_retry(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            pytest.raises(ModelRetry, match="Ongeldige datum"),
        ):
            await check_availability_impl(fake_ctx, "2026-06-15", "seven", 2)

    async def test_party_too_large(self, fake_ctx):
        restaurant = _restaurant_with_settings({"max_party_size": "4"})
        fake_ctx.deps.session.execute.return_value = _scalar_result(restaurant)

        # Use a future date so we don't hit "too_soon"
        future = datetime.now(UTC) + timedelta(days=2)
        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 6)
        assert result["available"] is False
        assert result["reason"] == "party_too_large"
        assert result["max_party_size"] == 4

    async def test_too_soon(self, fake_ctx):
        restaurant = _restaurant_with_settings({"min_advance_hours": "2"})
        fake_ctx.deps.session.execute.return_value = _scalar_result(restaurant)

        # Request 30 min from now — within min_advance_hours=2
        soon = datetime.now(UTC) + timedelta(minutes=30)
        result = await check_availability_impl(
            fake_ctx, soon.strftime("%Y-%m-%d"), soon.strftime("%H:%M"), 2
        )
        assert result["available"] is False
        assert result["reason"] == "too_soon"

    async def test_too_far_in_advance(self, fake_ctx):
        restaurant = _restaurant_with_settings({"max_advance_days": "7"})
        fake_ctx.deps.session.execute.return_value = _scalar_result(restaurant)

        far = datetime.now(UTC) + timedelta(days=30)
        result = await check_availability_impl(fake_ctx, far.strftime("%Y-%m-%d"), "19:00", 2)
        assert result["available"] is False
        assert result["reason"] == "too_far_in_advance"

    async def test_closed_no_matching_block(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        future = datetime.now(UTC) + timedelta(days=2)

        fake_ctx.deps.session.execute.return_value = _scalar_result(restaurant)

        with (
            patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock) as mock_resolve,
            patch(f"{TOOLS_MOD}.find_block_for_time") as mock_find,
        ):
            mock_resolve.return_value = []
            mock_find.return_value = None

            result = await check_availability_impl(
                fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 2
            )
        assert result["available"] is False
        assert result["reason"] == "closed"

    async def test_no_valid_slot(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)

        fake_ctx.deps.session.execute.return_value = _scalar_result(restaurant)

        with (
            patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock) as mock_resolve,
            patch(f"{TOOLS_MOD}.find_block_for_time") as mock_find,
            patch(f"{TOOLS_MOD}.snap_to_interval") as mock_snap,
        ):
            mock_resolve.return_value = [matched]
            mock_find.return_value = matched
            mock_snap.return_value = None

            result = await check_availability_impl(
                fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 2
            )
        assert result["available"] is False
        assert result["reason"] == "no_valid_slot"
        # ``suggested_times`` is intentionally not exposed to the agent —
        # the proactive-alternatives flow uses ``find_available_slots``.
        assert "suggested_times" not in result

    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_available(self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx):
        restaurant = _restaurant_with_settings()
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)
        snapped = time(19, 0)

        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = snapped
        mock_table.return_value = True

        # 1) restaurant query, 2) existing-reservation count
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),  # no existing reservations
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 2)
        assert result["available"] is True
        assert result["time"] == "19:00"
        assert result["snapped"] is False
        # Internal scheduling fields MUST NOT leak to the agent.
        for leaked in (
            "block_id",
            "available_covers",
            "block_start_time",
            "block_end_time",
            "combo_options",
        ):
            assert leaked not in result

    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_no_tables_available(
        self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx
    ):
        restaurant = _restaurant_with_settings()
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)

        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = time(19, 0)
        mock_table.return_value = False

        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),  # no existing reservations
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 2)
        assert result["available"] is False
        assert result["reason"] == "no_tables_available"

    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_fully_booked(self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx):
        restaurant = _restaurant_with_settings()
        block = _service_block(max_covers=10)
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)

        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = time(19, 0)

        # 8 existing covers + requesting 4 = 12 > 10
        existing_res = _reservation(party_size=8)
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([existing_res]),
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 4)
        assert result["available"] is False
        assert result["reason"] == "fully_booked"
        # Booked / max cover counts are internal and MUST NOT leak.
        assert "booked_covers" not in result
        assert "max_covers" not in result


# ===================================================================
# create_reservation_impl
# ===================================================================


class TestCreateReservation:
    async def test_invalid_date_raises_model_retry(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            pytest.raises(ModelRetry, match="Ongeldige datum"),
        ):
            await create_reservation_impl(
                fake_ctx, "Jan", "jan@example.com", None, "bad-date", "19:00", 2
            )

    async def test_invalid_time_raises_model_retry(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            pytest.raises(ModelRetry, match="Ongeldige datum"),
        ):
            await create_reservation_impl(
                fake_ctx, "Jan", "jan@example.com", None, "2026-06-15", "nope", 2
            )

    async def test_empty_guest_name_returns_error(self, fake_ctx):
        result = await create_reservation_impl(
            fake_ctx, "   ", "jan@example.com", None, "2026-06-15", "19:00", 2
        )
        assert result == {"error": "Guest name is required"}

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_idempotent_reuse(self, mock_restate, fake_ctx):
        existing = _reservation(id="res-existing", status="confirmed")
        restaurant = _restaurant_with_settings()

        with patch(
            f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant
        ):
            fake_ctx.deps.session.execute.side_effect = [
                _scalar_result(existing),  # idempotency check finds match
            ]

            result = await create_reservation_impl(
                fake_ctx,
                "Jan Jansen",
                "jan@example.com",
                None,
                "2026-06-15",
                "19:00",
                2,
            )
        assert result["idempotent_reuse"] is True
        assert result["reservation_id"] == "res-existing"
        mock_restate.assert_not_called()

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_creates_new_reservation_existing_customer(self, mock_restate, fake_ctx):
        customer = MagicMock()
        customer.id = "cust-existing"
        restaurant = _restaurant_with_settings()

        with patch(
            f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant
        ):
            fake_ctx.deps.session.execute.side_effect = [
                _scalar_result(None),  # no idempotent match
                _scalar_result(customer),  # existing customer
            ]

            result = await create_reservation_impl(
                fake_ctx,
                "jan jansen",
                "JAN@example.com",
                "+31600000000",
                "2026-06-15",
                "19:00",
                4,
                notes="Window seat",
            )
        assert result["idempotent_reuse"] is False
        assert result["status"] == "accepted"
        assert "reservation_id" in result

        # Verify restate_proxy called with correct workflow
        mock_restate.assert_awaited_once()
        call_args = mock_restate.call_args
        assert call_args[0][1] == "ReservationWorkflow"
        assert call_args[0][3] == "create_reservation"
        payload = call_args[0][4]
        assert payload["customer_id"] == "cust-existing"
        assert payload["guest_name"] == "Jan Jansen"  # .strip().title()
        assert payload["guest_email"] == "jan@example.com"  # .strip().lower()
        assert payload["notes"] == "Window seat"

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_creates_new_customer_when_none_exists(self, mock_restate, fake_ctx):
        restaurant = _restaurant_with_settings()

        with patch(
            f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant
        ):
            fake_ctx.deps.session.execute.side_effect = [
                _scalar_result(None),  # no idempotent match
                _scalar_result(None),  # no existing customer
            ]

            result = await create_reservation_impl(
                fake_ctx, "New Guest", "new@example.com", None, "2026-06-15", "19:00", 2
            )
        assert result["idempotent_reuse"] is False
        assert result["status"] == "accepted"

        # session.add called for new Customer
        fake_ctx.deps.session.add.assert_called_once()
        fake_ctx.deps.session.commit.assert_awaited_once()


# ===================================================================
# cancel_reservation_impl
# ===================================================================


class TestCancelReservation:
    async def test_not_found_raises(self, fake_ctx):
        fake_ctx.deps.session.execute.return_value = _scalar_result(None)

        with pytest.raises(ModelRetry, match="niet gevonden"):
            await cancel_reservation_impl(fake_ctx, "nonexistent-id")

    async def test_already_cancelled(self, fake_ctx):
        res = _reservation(status="cancelled")
        fake_ctx.deps.session.execute.return_value = _scalar_result(res)

        result = await cancel_reservation_impl(fake_ctx, "res-1")
        assert result["status"] == "already_cancelled"

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_active_reservation_cancelled(self, mock_restate, fake_ctx):
        res = _reservation(status="confirmed", customer_id=None)
        fake_ctx.deps.session.execute.return_value = _scalar_result(res)

        result = await cancel_reservation_impl(fake_ctx, "res-1")
        assert result["status"] == "cancelled"

        mock_restate.assert_awaited_once()
        call_args = mock_restate.call_args
        assert call_args[0][1] == "ReservationObject"
        assert call_args[0][3] == "update_status"
        assert call_args[0][4]["status"] == "cancelled"

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_cancellation_sends_email_on_success(self, mock_restate, fake_ctx):
        """When customer exists, cancellation attempts to send email."""
        res = _reservation(status="confirmed", customer_id="cust-1")
        customer = MagicMock()
        restaurant = _restaurant_with_settings()

        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(res),  # load reservation
            _scalar_result(customer),  # load customer
            _scalar_result(restaurant),  # load restaurant for email
        ]

        with patch("app.email.scaleway.ScalewayEmailClient") as MockEmail:
            mock_email_instance = MockEmail.return_value
            mock_email_instance.send_reservation_cancellation = AsyncMock()

            result = await cancel_reservation_impl(fake_ctx, "res-1")
        assert result["status"] == "cancelled"

    @patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock)
    async def test_cancellation_email_failure_does_not_raise(self, mock_restate, fake_ctx):
        """Email sending failure is logged but doesn't break cancellation."""
        res = _reservation(status="confirmed", customer_id="cust-1")
        customer = MagicMock()
        restaurant = _restaurant_with_settings()

        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(res),
            _scalar_result(customer),
            _scalar_result(restaurant),
        ]

        with patch("app.email.scaleway.ScalewayEmailClient") as MockEmail:
            MockEmail.return_value.send_reservation_cancellation = AsyncMock(
                side_effect=Exception("SMTP down")
            )
            result = await cancel_reservation_impl(fake_ctx, "res-1")

        # Cancellation still succeeds
        assert result["status"] == "cancelled"

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_unverified_non_dashboard_requires_verification(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = False
        fake_ctx.deps.session.execute.reset_mock()

        result = await cancel_reservation_impl(fake_ctx, "res-1")

        assert result == {"error": "verification_required"}
        fake_ctx.deps.session.execute.assert_not_awaited()
        mock_logfire.assert_called_once_with(
            "reservation_tool_access",
            caller_channel="website",
            caller_verified=False,
            caller_customer_id=None,
            restaurant_id="test-restaurant-id",
            tool_name="cancel_reservation",
            authorization_decision="denied",
            reservation_id="res-1",
        )

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_verified_non_dashboard_checks_ownership(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = "cust-2"
        fake_ctx.deps.session.execute.return_value = _scalar_result(
            _reservation(status="confirmed", customer_id="cust-1")
        )

        result = await cancel_reservation_impl(fake_ctx, "res-1")

        assert result == {"error": "forbidden"}
        fake_ctx.deps.session.execute.assert_awaited_once()
        mock_logfire.assert_called_once()

    async def test_whatsapp_unlinked_caller_rejects_orphan_reservation(self, fake_ctx):
        """Regression: WhatsApp caller whose phone matched no Customer
        (caller.customer_id is None) MUST NOT be allowed to cancel an
        orphan reservation (reservation.customer_id is None) — previously
        the `!=` check let `None != None` slip through."""
        fake_ctx.deps.caller.channel = "whatsapp"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = None
        fake_ctx.deps.session.execute.return_value = _scalar_result(
            _reservation(status="confirmed", customer_id=None)
        )

        result = await cancel_reservation_impl(fake_ctx, "res-1")

        assert result == {"error": "forbidden"}

    async def test_whatsapp_unlinked_caller_rejects_other_customers_reservation(self, fake_ctx):
        """Same scenario, but the reservation has an owner. Still forbidden."""
        fake_ctx.deps.caller.channel = "whatsapp"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = None
        fake_ctx.deps.session.execute.return_value = _scalar_result(
            _reservation(status="confirmed", customer_id="cust-99")
        )

        result = await cancel_reservation_impl(fake_ctx, "res-1")

        assert result == {"error": "forbidden"}


# ===================================================================
# find_reservation_impl
# ===================================================================


class TestFindReservation:
    async def test_empty_results(self, fake_ctx):
        fake_ctx.deps.session.execute.return_value = _scalars_result([])

        result = await find_reservation_impl(fake_ctx, customer_email="nobody@test.com")
        assert result["reservations"] == []
        assert "Geen" in result["message"]

    async def test_returns_formatted_results(self, fake_ctx):
        r1 = _reservation(id="r1", guest_name="Alice", guest_email="alice@test.com")
        r2 = _reservation(id="r2", guest_name="Bob", guest_email="bob@test.com", party_size=4)
        fake_ctx.deps.session.execute.return_value = _scalars_result([r1, r2])

        result = await find_reservation_impl(fake_ctx, customer_name="test")
        assert len(result["reservations"]) == 2
        assert result["reservations"][0]["reservation_id"] == "r1"
        assert result["reservations"][1]["party_size"] == 4

    async def test_search_by_date(self, fake_ctx):
        fake_ctx.deps.session.execute.return_value = _scalars_result([])

        result = await find_reservation_impl(fake_ctx, date="2026-06-15")
        assert result["reservations"] == []
        # The date filter now requires loading the restaurant's timezone
        # to build a naive-UTC ``[start, end)`` day window — verify that
        # the second execute() is the actual reservation query (vs. the
        # tz lookup) by counting at least 2 calls.
        assert fake_ctx.deps.session.execute.await_count >= 2

    async def test_invalid_date_ignored(self, fake_ctx):
        """Invalid date string doesn't crash — the filter is just skipped."""
        fake_ctx.deps.session.execute.return_value = _scalars_result([])

        result = await find_reservation_impl(fake_ctx, date="not-a-date")
        assert result["reservations"] == []

    async def test_no_filters(self, fake_ctx):
        """Calling with no filters still works (returns all for restaurant)."""
        fake_ctx.deps.session.execute.return_value = _scalars_result([])

        result = await find_reservation_impl(fake_ctx)
        assert result["reservations"] == []

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_unverified_non_dashboard_requires_verification(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = False
        fake_ctx.deps.session.execute.reset_mock()

        result = await find_reservation_impl(fake_ctx, customer_email="jan@example.com")

        assert result["error"] == "verification_required"
        fake_ctx.deps.session.execute.assert_not_awaited()
        mock_logfire.assert_called_once_with(
            "reservation_tool_access",
            caller_channel="website",
            caller_verified=False,
            caller_customer_id=None,
            restaurant_id="test-restaurant-id",
            tool_name="find_reservation",
            authorization_decision="denied",
        )

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_verified_guest_is_scoped_and_redacted(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = "cust-1"
        fake_ctx.deps.session.execute.return_value = _scalars_result(
            [_reservation(id="r1", guest_email="alice@test.com", customer_id="cust-1")]
        )

        result = await find_reservation_impl(
            fake_ctx,
            customer_email="ignored@example.com",
            customer_name="ignored",
        )

        assert result["reservations"] == [
            {
                "reservation_id": "r1",
                "reserved_at": "2026-06-15T19:00:00",
                "party_size": 2,
                "status": "confirmed",
                "guest_name": "Jan Jansen",
            }
        ]
        assert "guest_email" not in result["reservations"][0]
        fake_ctx.deps.session.execute.assert_awaited_once()
        mock_logfire.assert_called_once()

    async def test_verified_guest_without_customer_id_gets_empty_results(self, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = None
        fake_ctx.deps.session.execute.reset_mock()

        result = await find_reservation_impl(fake_ctx)

        assert result == {"reservations": [], "message": "Geen reserveringen gevonden"}
        fake_ctx.deps.session.execute.assert_not_awaited()


# ===================================================================
# get_restaurant_info_impl
# ===================================================================


class TestGetRestaurantInfo:
    async def test_restaurant_not_found(self, fake_ctx):
        fake_ctx.deps.session.execute.return_value = _scalar_result(None)

        result = await get_restaurant_info_impl(fake_ctx)
        assert result == {"error": "Restaurant niet gevonden"}

    async def test_returns_info_with_opening_hours(self, fake_ctx):
        restaurant = _restaurant_with_settings(
            {
                "description": "Cosy bistro",
                "cancellation_policy": "Free until 24h before",
                "address_street": "Rue de Test 1",
                "address_city": "Brussels",
                "address_postcode": "1000",
                "address_country": "BE",
            }
        )
        block_mon = _service_block(day_of_week=0, start_h=17, end_h=22, block_type="open")
        block_closed = _service_block(day_of_week=1, block_type="closed")

        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([block_mon, block_closed]),
        ]

        result = await get_restaurant_info_impl(fake_ctx)
        assert result["name"] == "Test Restaurant"
        assert result["phone"] == "+31612345678"
        assert result["timezone"] == "Europe/Amsterdam"
        assert "maandag" in result["opening_hours"]
        # "closed" block_type is excluded from opening_hours.
        assert "dinsdag" not in result["opening_hours"]
        # Guest-relevant settings come through under explicit keys.
        assert result["description"] == "Cosy bistro"
        assert result["cancellation_policy"] == "Free until 24h before"
        assert result["address"] == {
            "street": "Rue de Test 1",
            "city": "Brussels",
            "postcode": "1000",
            "country": "BE",
        }

    async def test_only_allowlisted_settings_leak_to_agent(self, fake_ctx):
        # Every key here is internal — none must surface in the response.
        internal_settings = {
            "min_advance_hours": 2,
            "max_advance_days": 30,
            "max_party_size": 10,
            "in_service_mode": "auto",
            "in_service_duration_minutes": 90,
            "dish_chooser_enabled": True,
            "dish_chooser_max_dishes": 5,
            "reservation_mode": "manual",
            "agents": {"reservation": {"custom_prompt_suffix": "Be cheeky"}},
            "reservation_enabled": True,
            "webhook_url": "https://internal.example/hook",
            "custom_undocumented_key": "still internal",
        }
        restaurant = _restaurant_with_settings(internal_settings)
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),
        ]

        result = await get_restaurant_info_impl(fake_ctx)

        # Hard-coded allowlist: only these top-level keys may appear.
        allowed = {"name", "phone", "timezone", "opening_hours"}
        assert set(result.keys()) <= allowed, (
            f"Internal settings leaked to agent: {set(result.keys()) - allowed}"
        )


class TestModifyReservation:
    async def test_successful_time_change(self, fake_ctx):
        """Modify time: verify availability checked and update proxied."""
        reservation = _reservation(status="confirmed")
        restaurant = _restaurant_with_settings()

        with (
            patch(
                f"{TOOLS_MOD}.check_availability_impl",
                new_callable=AsyncMock,
                return_value={"available": True},
            ) as mock_avail,
            patch(
                f"{TOOLS_MOD}._load_restaurant",
                new_callable=AsyncMock,
                return_value=restaurant,
            ),
            patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock) as mock_proxy,
        ):
            fake_ctx.deps.session.execute.return_value = _scalar_result(reservation)
            result = await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

        assert result["status"] == "modified"
        assert "time" in result["updated_fields"]
        mock_avail.assert_awaited_once()
        mock_proxy.assert_awaited_once()

    async def test_party_size_change(self, fake_ctx):
        reservation = _reservation(status="confirmed")
        restaurant = _restaurant_with_settings()

        with (
            patch(
                f"{TOOLS_MOD}.check_availability_impl",
                new_callable=AsyncMock,
                return_value={"available": True},
            ),
            patch(
                f"{TOOLS_MOD}._load_restaurant",
                new_callable=AsyncMock,
                return_value=restaurant,
            ),
            patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock),
        ):
            fake_ctx.deps.session.execute.return_value = _scalar_result(reservation)
            result = await modify_reservation_impl(fake_ctx, "res-1", party_size=6)

        assert result["status"] == "modified"
        assert "party_size" in result["updated_fields"]

    async def test_unavailable_slot_raises_model_retry(self, fake_ctx):
        reservation = _reservation(status="confirmed")
        restaurant = _restaurant_with_settings()

        with (
            patch(
                f"{TOOLS_MOD}.check_availability_impl",
                new_callable=AsyncMock,
                return_value={"available": False, "reason": "fully_booked"},
            ),
            patch(
                f"{TOOLS_MOD}._load_restaurant",
                new_callable=AsyncMock,
                return_value=restaurant,
            ),
        ):
            fake_ctx.deps.session.execute.return_value = _scalar_result(reservation)
            with pytest.raises(ModelRetry):
                await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

    async def test_reservation_not_found_raises_model_retry(self, fake_ctx):
        fake_ctx.deps.session.execute.return_value = _scalar_result(None)
        with pytest.raises(ModelRetry):
            await modify_reservation_impl(fake_ctx, "res-not-found", time="20:00")

    async def test_cancelled_reservation_raises_model_retry(self, fake_ctx):
        reservation = _reservation(status="cancelled")
        fake_ctx.deps.session.execute.return_value = _scalar_result(reservation)
        with pytest.raises(ModelRetry):
            await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

    async def test_notes_only_change_skips_availability_check(self, fake_ctx):
        reservation = _reservation(status="confirmed")

        with (
            patch(f"{TOOLS_MOD}.check_availability_impl", new_callable=AsyncMock) as mock_avail,
            patch(f"{TOOLS_MOD}.restate_proxy", new_callable=AsyncMock),
        ):
            fake_ctx.deps.session.execute.return_value = _scalar_result(reservation)
            result = await modify_reservation_impl(fake_ctx, "res-1", notes="Window seat please")

        assert result["status"] == "modified"
        assert "notes" in result["updated_fields"]
        mock_avail.assert_not_awaited()

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_unverified_non_dashboard_requires_verification(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = False
        fake_ctx.deps.session.execute.reset_mock()

        result = await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

        assert result == {"error": "verification_required"}
        fake_ctx.deps.session.execute.assert_not_awaited()
        mock_logfire.assert_called_once_with(
            "reservation_tool_access",
            caller_channel="website",
            caller_verified=False,
            caller_customer_id=None,
            restaurant_id="test-restaurant-id",
            tool_name="modify_reservation",
            authorization_decision="denied",
            reservation_id="res-1",
        )

    @patch(f"{TOOLS_MOD}.logfire.info")
    async def test_verified_non_dashboard_checks_ownership(self, mock_logfire, fake_ctx):
        fake_ctx.deps.caller.channel = "website"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = "cust-2"
        fake_ctx.deps.session.execute.return_value = _scalar_result(
            _reservation(status="confirmed", customer_id="cust-1")
        )

        result = await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

        assert result == {"error": "forbidden"}
        fake_ctx.deps.session.execute.assert_awaited_once()
        mock_logfire.assert_called_once()

    async def test_whatsapp_unlinked_caller_rejects_orphan_modify(self, fake_ctx):
        """Regression: WhatsApp caller without a linked Customer MUST NOT
        be able to modify an orphan reservation."""
        fake_ctx.deps.caller.channel = "whatsapp"
        fake_ctx.deps.caller.verified = True
        fake_ctx.deps.caller.customer_id = None
        fake_ctx.deps.session.execute.return_value = _scalar_result(
            _reservation(status="confirmed", customer_id=None)
        )

        result = await modify_reservation_impl(fake_ctx, "res-1", time="20:00")

        assert result == {"error": "forbidden"}


# ===================================================================
# Dish pre-selection surfacing (regression test for trace
# 019e66c0bfe6c2b67fb50a77e5c57361 — agent only learned about the
# dish requirement after a failed create_reservation call and got
# stuck searching a non-existent menu instead of asking the guest)
# ===================================================================


class TestDishSelectionSurfacing:
    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_check_availability_surfaces_freetext_advice(
        self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx
    ):
        restaurant = _restaurant_with_settings(
            {
                "dish_chooser_enabled": True,
                "dish_chooser_min_party_size": 6,
                "dish_chooser_max_dishes": 4,
                "dish_chooser_from_menu": False,
            }
        )
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)
        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = time(19, 0)
        mock_table.return_value = True
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),
            _scalars_result([]),
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 10)

        assert result["available"] is True
        assert result["dish_selection"] == {
            "required": True,
            "mode": "freetext",
            "max_distinct": 4,
        }

    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_check_availability_surfaces_menu_advice(
        self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx
    ):
        restaurant = _restaurant_with_settings(
            {
                "dish_chooser_enabled": True,
                "dish_chooser_min_party_size": 8,
                "dish_chooser_max_dishes": 6,
                "dish_chooser_from_menu": True,
            }
        )
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)
        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = time(19, 0)
        mock_table.return_value = True
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),
            _scalars_result([]),
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 8)

        assert result["dish_selection"] == {
            "required": True,
            "mode": "menu",
            "max_distinct": 6,
        }

    @patch(f"{TOOLS_MOD}.has_any_available_table", new_callable=AsyncMock)
    @patch(f"{TOOLS_MOD}.snap_to_interval")
    @patch(f"{TOOLS_MOD}.find_block_for_time")
    @patch(f"{TOOLS_MOD}.resolve_service_blocks", new_callable=AsyncMock)
    async def test_check_availability_omits_advice_when_not_required(
        self, mock_resolve, mock_find, mock_snap, mock_table, fake_ctx
    ):
        restaurant = _restaurant_with_settings(
            {
                "dish_chooser_enabled": True,
                "dish_chooser_min_party_size": 8,
                "dish_chooser_from_menu": False,
            }
        )
        block = _service_block()
        matched = _matching_block(block)
        future = datetime.now(UTC) + timedelta(days=2)
        mock_resolve.return_value = [matched]
        mock_find.return_value = matched
        mock_snap.return_value = time(19, 0)
        mock_table.return_value = True
        fake_ctx.deps.session.execute.side_effect = [
            _scalar_result(restaurant),
            _scalars_result([]),
            _scalars_result([]),
        ]

        result = await check_availability_impl(fake_ctx, future.strftime("%Y-%m-%d"), "19:00", 4)

        assert "dish_selection" not in result

    async def test_create_reservation_freetext_error_instructs_chat(self, fake_ctx):
        restaurant = _restaurant_with_settings(
            {
                "dish_chooser_enabled": True,
                "dish_chooser_min_party_size": 6,
                "dish_chooser_max_dishes": 4,
                "dish_chooser_from_menu": False,
            }
        )

        with patch(
            f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant
        ):
            result = await create_reservation_impl(
                fake_ctx,
                "Pieter Van Oost",
                "pieter@example.com",
                "+32473784531",
                "2026-06-15",
                "12:00",
                10,
            )

        assert result["error"] == "dish_selection_required"
        assert result["mode"] == "freetext"
        assert result["max_distinct"] == 4
        assert result["party_size"] == 10
        # Hint must not reference search_menu when free-text is accepted.
        assert "search_menu" not in result["message"]
        assert "dishes_text" in result["message"]
        # Frames the requirement as ours, not "the restaurant requires…".
        assert "we ask" in result["message"]
        assert "first person plural" in result["message"]

    async def test_create_reservation_menu_error_instructs_search_menu(self, fake_ctx):
        restaurant = _restaurant_with_settings(
            {
                "dish_chooser_enabled": True,
                "dish_chooser_min_party_size": 6,
                "dish_chooser_max_dishes": 5,
                "dish_chooser_from_menu": True,
            }
        )

        with patch(
            f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant
        ):
            result = await create_reservation_impl(
                fake_ctx,
                "Pieter Van Oost",
                "pieter@example.com",
                "+32473784531",
                "2026-06-15",
                "12:00",
                8,
            )

        assert result["error"] == "dish_selection_required"
        assert result["mode"] == "menu"
        assert result["max_distinct"] == 5
        assert "search_menu" in result["message"]
        assert "dishes" in result["message"]
        # Frames the requirement as ours, not "the restaurant requires…".
        assert "we ask" in result["message"]
        assert "first person plural" in result["message"]


# ===================================================================
# find_available_slots_impl
# ===================================================================


class TestFindAvailableSlots:
    async def test_invalid_date_raises_model_retry(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            pytest.raises(ModelRetry, match="Ongeldige datum"),
        ):
            await find_available_slots_impl(fake_ctx, "not-a-date", 4)

    async def test_restaurant_not_found(self, fake_ctx):
        with patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=None):
            result = await find_available_slots_impl(fake_ctx, "2026-06-15", 4)
        assert result["available_slots"] == []
        assert result["reason"] == "restaurant_not_found"

    async def test_delegates_to_compute_available_slots(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        payload = {
            "available_slots": ["12:30", "13:30", "14:00"],
            "combo_options": [{"id": "c1", "name": "Combo", "combined_capacity": 14}],
            "reason": None,
        }
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            patch(
                f"{TOOLS_MOD}.compute_available_slots",
                new_callable=AsyncMock,
                return_value=payload,
            ) as mock_compute,
        ):
            result = await find_available_slots_impl(fake_ctx, "2026-06-15", 14)

        mock_compute.assert_awaited_once()
        # Tool echoes the query and strips internal fields (combo_options)
        # before handing the response to the agent.
        assert result == {
            "date": "2026-06-15",
            "party_size": 14,
            "available_slots": ["12:30", "13:30", "14:00"],
            "reason": None,
        }
        assert "combo_options" not in result

    async def test_propagates_fully_booked_reason(self, fake_ctx):
        restaurant = _restaurant_with_settings()
        with (
            patch(f"{TOOLS_MOD}._load_restaurant", new_callable=AsyncMock, return_value=restaurant),
            patch(
                f"{TOOLS_MOD}.compute_available_slots",
                new_callable=AsyncMock,
                return_value={
                    "available_slots": [],
                    "combo_options": [],
                    "reason": "fully_booked",
                },
            ),
        ):
            result = await find_available_slots_impl(fake_ctx, "2026-06-01", 14)
        assert result["available_slots"] == []
        assert result["reason"] == "fully_booked"
