from __future__ import annotations

import importlib
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, MagicMock, patch

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

_REPO_ROOT = Path(__file__).resolve().parents[2]
_BACKEND_PATH = _REPO_ROOT / "backend"
_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))

reservation = importlib.import_module("objects.reservation")

from datetime import UTC, datetime  # noqa: E402

from restate import TerminalError  # noqa: E402


class _MockObjectContext:
    def __init__(self, reservation_id: str) -> None:
        self._reservation_id = reservation_id

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

    async def run(self, _step_name: str, async_fn) -> object:
        return await async_fn()

    def service_send(self, *args, **kwargs) -> None:
        pass


class _MockReservation:
    def __init__(self, id: str, status: str) -> None:
        self.id = id
        self.status = status
        self.restaurant_id = "restaurant-test-001"
        self.completed_at = None
        self.end_time = datetime(2024, 1, 1, 20, 0)
        self.guest_email = "test@example.com"
        self.guest_name = "Test Guest"
        self.customer_id = "customer-test-001"
        self.operator_comment = None
        # Also used as restaurant and customer stand-in by _MockSession
        self.reserved_at = datetime(2024, 1, 1, 18, 0)
        self.party_size = 2
        self.name = "Test Restaurant"
        self.phone = "+31600000000"
        self.email = "test@example.com"


class _MockSession:
    def __init__(self, reservation: _MockReservation | None = None) -> None:
        self.reservation = reservation

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        pass

    async def execute(self, _stmt):
        result = MagicMock()
        result.scalar_one_or_none.return_value = self.reservation
        return result

    async def commit(self):
        pass


class TestReservationLifecycleTransitions(IsolatedAsyncioTestCase):
    @staticmethod
    @asynccontextmanager
    async def _patched_db(reservation_obj):
        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with (
            patch("objects.reservation.get_db_session", mock_get_db),
            patch("event_publisher.publish_event", new_callable=AsyncMock),
            patch("objects.reservation._create_in_app_notification", new_callable=AsyncMock),
            patch("app.email.scaleway.ScalewayEmailClient._send", new_callable=AsyncMock),
        ):
            yield

    async def test_approve_pending_transitions_to_confirmed(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "pending")

        async with self._patched_db(reservation_obj):
            result = await reservation.approve(ctx, {})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "confirmed")
            self.assertEqual(reservation_obj.status, "confirmed")

    async def test_approve_confirmed_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "confirmed")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.approve(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_approve_cancelled_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "cancelled")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.approve(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_approve_seated_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "seated")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.approve(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_approve_completed_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "completed")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.approve(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_approve_missing_reservation_raises_not_found(self) -> None:
        ctx = _MockObjectContext("res-missing")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(None)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.approve(ctx, {})

            self.assertEqual(cm.exception.status_code, 404)

    async def test_reject_pending_transitions_to_cancelled(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "pending")

        async with self._patched_db(reservation_obj):
            result = await reservation.reject(ctx, {})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "cancelled")
            self.assertEqual(reservation_obj.status, "cancelled")

    async def test_reject_confirmed_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "confirmed")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.reject(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_reject_seated_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "seated")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.reject(ctx, {})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_reject_missing_reservation_raises_not_found(self) -> None:
        ctx = _MockObjectContext("res-missing")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(None)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.reject(ctx, {})

            self.assertEqual(cm.exception.status_code, 404)

    def test_transition_matrix_seated_allows_cancelled(self) -> None:
        self.assertIn("cancelled", reservation._TRANSITIONS["seated"])

    def test_transition_matrix_completed_allows_in_service(self) -> None:
        self.assertEqual(reservation._TRANSITIONS["completed"], {"in_service"})

    def test_transition_matrix_no_show_is_terminal(self) -> None:
        self.assertEqual(reservation._TRANSITIONS["no_show"], set())

    def test_transition_matrix_cancelled_is_terminal(self) -> None:
        self.assertEqual(reservation._TRANSITIONS["cancelled"], set())

    def test_transition_matrix_forward_skip_rejected(self) -> None:
        self.assertNotIn("completed", reservation._TRANSITIONS["confirmed"])

    def test_transition_matrix_pending_to_confirmed(self) -> None:
        self.assertIn("confirmed", reservation._TRANSITIONS["pending"])

    def test_transition_matrix_pending_to_cancelled(self) -> None:
        self.assertIn("cancelled", reservation._TRANSITIONS["pending"])

    def test_transition_matrix_confirmed_to_seated(self) -> None:
        self.assertIn("seated", reservation._TRANSITIONS["confirmed"])

    def test_transition_matrix_confirmed_to_no_show(self) -> None:
        self.assertIn("no_show", reservation._TRANSITIONS["confirmed"])

    def test_transition_matrix_seated_to_completed(self) -> None:
        self.assertIn("completed", reservation._TRANSITIONS["seated"])

    def test_transition_matrix_completed_to_in_service(self) -> None:
        self.assertIn("in_service", reservation._TRANSITIONS["completed"])

    async def test_update_status_pending_to_confirmed(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "pending")

        async with self._patched_db(reservation_obj):
            result = await reservation.update_status(ctx, {"status": "confirmed"})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "confirmed")
            self.assertEqual(reservation_obj.status, "confirmed")

    async def test_update_status_confirmed_to_seated(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "confirmed")

        async with self._patched_db(reservation_obj):
            result = await reservation.update_status(ctx, {"status": "seated"})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "seated")
            self.assertEqual(reservation_obj.status, "seated")

    async def test_update_status_seated_to_completed(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "seated")

        async with self._patched_db(reservation_obj):
            result = await reservation.update_status(ctx, {"status": "completed"})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "completed")
            self.assertEqual(reservation_obj.status, "completed")
            # completed_at should be set to current UTC time (auto-generated)
            self.assertIsNotNone(reservation_obj.completed_at)
            # end_time should be updated to match completed_at
            self.assertEqual(reservation_obj.end_time, reservation_obj.completed_at)

    async def test_update_status_completed_with_explicit_completed_at(self) -> None:
        from datetime import datetime

        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "seated")
        explicit_time = datetime(2026, 2, 26, 19, 30, 0, tzinfo=UTC)

        async with self._patched_db(reservation_obj):
            result = await reservation.update_status(
                ctx, {"status": "completed", "completed_at": explicit_time.isoformat()}
            )

            self.assertEqual(result["status"], "completed")
            # The handler strips timezone for DB storage
            self.assertEqual(reservation_obj.completed_at, explicit_time.replace(tzinfo=None))
            self.assertEqual(reservation_obj.end_time, explicit_time.replace(tzinfo=None))

    async def test_update_status_completed_without_completed_at_defaults_to_now(self) -> None:
        from datetime import datetime

        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "seated")

        # Storage convention is naive-UTC (see ``app.utils.tz``), so the
        # handler's default fires ``utcnow()`` — compare against naive
        # bounds, not tz-aware ones.
        before = datetime.now(UTC).replace(tzinfo=None)
        async with self._patched_db(reservation_obj):
            await reservation.update_status(ctx, {"status": "completed"})
        after = datetime.now(UTC).replace(tzinfo=None)

        self.assertIsNotNone(reservation_obj.completed_at)
        assert reservation_obj.completed_at is not None
        self.assertIsNone(reservation_obj.completed_at.tzinfo)
        self.assertGreaterEqual(reservation_obj.completed_at, before)
        self.assertLessEqual(reservation_obj.completed_at, after)
        self.assertEqual(reservation_obj.end_time, reservation_obj.completed_at)

    async def test_update_status_confirmed_to_no_show(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "confirmed")

        async with self._patched_db(reservation_obj):
            result = await reservation.update_status(ctx, {"status": "no_show"})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "no_show")
            self.assertEqual(reservation_obj.status, "no_show")

    async def test_update_status_confirmed_to_completed_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "confirmed")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.update_status(ctx, {"status": "completed"})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_update_status_pending_to_seated_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "pending")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.update_status(ctx, {"status": "seated"})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_update_status_completed_to_cancelled_raises_terminal_error(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "completed")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.update_status(ctx, {"status": "cancelled"})

            self.assertEqual(cm.exception.status_code, 409)

    async def test_update_status_missing_reservation_raises_not_found(self) -> None:
        ctx = _MockObjectContext("res-missing")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(None)

        with patch("objects.reservation.get_db_session", mock_get_db):
            with self.assertRaises(TerminalError) as cm:
                await reservation.update_status(ctx, {"status": "confirmed"})

            self.assertEqual(cm.exception.status_code, 404)

    async def test_update_status_cancelled_to_cancelled_is_idempotent(self) -> None:
        ctx = _MockObjectContext("res-1")
        reservation_obj = _MockReservation("res-1", "cancelled")

        @asynccontextmanager
        async def mock_get_db(*_args, **_kwargs):
            yield _MockSession(reservation_obj)

        with patch("objects.reservation.get_db_session", mock_get_db):
            result = await reservation.update_status(ctx, {"status": "cancelled"})

            self.assertEqual(result["id"], "res-1")
            self.assertEqual(result["status"], "cancelled")
