"""Tests for the SchedulingService."""

from __future__ import annotations

import asyncio
import uuid
from datetime import datetime, timedelta, timezone

import pytest

from elizaos_plugin_scheduling.error import MeetingNotFoundError, ParticipantNotFoundError
from elizaos_plugin_scheduling.service import SchedulingService, _date_from_minutes, _get_day_of_week
from elizaos_plugin_scheduling.storage import get_meeting_storage, get_reminder_storage
from elizaos_plugin_scheduling.types import (
    Availability,
    AvailabilityException,
    AvailabilityWindow,
    DayOfWeek,
    LocationType,
    Meeting,
    MeetingLocation,
    MeetingParticipant,
    MeetingStatus,
    Participant,
    ParticipantRole,
    ProposedSlot,
    Reminder,
    ReminderStatus,
    ReminderType,
    SchedulingConstraints,
    SchedulingRequest,
    SchedulingResult,
    SchedulingUrgency,
    TimeSlot,
)


class TestAvailabilityChecking:
    def test_is_available_weekday_business_hours(
        self, scheduling_service: SchedulingService, sample_availability: Availability
    ) -> None:
        # Monday 10:00 AM ET (15:00 UTC)
        dt = datetime(2025, 1, 20, 15, 0, 0, tzinfo=timezone.utc)
        assert scheduling_service.is_available_at(sample_availability, dt) is True

    def test_is_not_available_weekend(
        self, scheduling_service: SchedulingService, sample_availability: Availability
    ) -> None:
        # Saturday 10:00 AM ET (15:00 UTC)
        dt = datetime(2025, 1, 18, 15, 0, 0, tzinfo=timezone.utc)
        assert scheduling_service.is_available_at(sample_availability, dt) is False

    def test_is_not_available_too_early(
        self, scheduling_service: SchedulingService, sample_availability: Availability
    ) -> None:
        # Monday 7:00 AM ET (12:00 UTC)
        dt = datetime(2025, 1, 20, 12, 0, 0, tzinfo=timezone.utc)
        assert scheduling_service.is_available_at(sample_availability, dt) is False

    def test_exception_unavailable(self, scheduling_service: SchedulingService) -> None:
        avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=1020)],
            exceptions=[AvailabilityException(date="2025-01-20", unavailable=True)],
        )
        dt = datetime(2025, 1, 20, 10, 0, 0, tzinfo=timezone.utc)
        assert scheduling_service.is_available_at(avail, dt) is False

    def test_exception_override_times(self, scheduling_service: SchedulingService) -> None:
        avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=1020)],
            exceptions=[
                AvailabilityException(date="2025-01-20", start_minutes=600, end_minutes=720)
            ],
        )
        # 10:00 UTC = 600 minutes -> within exception window
        assert scheduling_service.is_available_at(
            avail, datetime(2025, 1, 20, 10, 0, 0, tzinfo=timezone.utc)
        ) is True
        # 13:00 UTC = 780 minutes -> outside exception window
        assert scheduling_service.is_available_at(
            avail, datetime(2025, 1, 20, 13, 0, 0, tzinfo=timezone.utc)
        ) is False


class TestAvailabilityPersistence:
    @pytest.mark.asyncio
    async def test_save_and_get(
        self, scheduling_service: SchedulingService, sample_availability: Availability
    ) -> None:
        entity_id = str(uuid.uuid4())
        await scheduling_service.save_availability(entity_id, sample_availability)
        result = await scheduling_service.get_availability(entity_id)

        assert result is not None
        assert result.time_zone == "America/New_York"
        assert len(result.weekly) == 5

    @pytest.mark.asyncio
    async def test_get_nonexistent(self, scheduling_service: SchedulingService) -> None:
        assert await scheduling_service.get_availability("nonexistent-id") is None


class TestSlotFinding:
    @pytest.mark.asyncio
    async def test_find_slots_single_participant(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id=str(uuid.uuid4()),
            name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[
                    AvailabilityWindow(day=d, start_minutes=540, end_minutes=1020)
                    for d in [DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED, DayOfWeek.THU, DayOfWeek.FRI]
                ],
            ),
        )

        request = SchedulingRequest(
            id=str(uuid.uuid4()),
            room_id=str(uuid.uuid4()),
            title="Solo Meeting",
            participants=[participant],
            constraints=SchedulingConstraints(min_duration_minutes=30, preferred_duration_minutes=30),
            urgency=SchedulingUrgency.FLEXIBLE,
            created_at=1700000000000,
        )

        result = await scheduling_service.find_available_slots(request)
        assert result.success is True
        assert 0 < len(result.proposed_slots) <= 3

    @pytest.mark.asyncio
    async def test_find_slots_no_participants(
        self, scheduling_service: SchedulingService
    ) -> None:
        request = SchedulingRequest(
            id=str(uuid.uuid4()),
            room_id=str(uuid.uuid4()),
            title="Empty Meeting",
            participants=[],
            created_at=1700000000000,
        )

        result = await scheduling_service.find_available_slots(request)
        assert result.success is False
        assert result.failure_reason == "No participants specified"

    @pytest.mark.asyncio
    async def test_find_slots_no_overlap(self, scheduling_service: SchedulingService) -> None:
        alice = Participant(
            entity_id=str(uuid.uuid4()), name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=600)],
            ),
        )
        bob = Participant(
            entity_id=str(uuid.uuid4()), name="Bob",
            availability=Availability(
                time_zone="UTC",
                weekly=[AvailabilityWindow(day=DayOfWeek.TUE, start_minutes=540, end_minutes=600)],
            ),
        )

        request = SchedulingRequest(
            id=str(uuid.uuid4()),
            room_id=str(uuid.uuid4()),
            title="No Overlap",
            participants=[alice, bob],
            constraints=SchedulingConstraints(min_duration_minutes=30),
            created_at=1700000000000,
        )

        result = await scheduling_service.find_available_slots(request)
        assert result.success is False
        assert result.conflicting_participants is not None

    @pytest.mark.asyncio
    async def test_slot_scoring_business_hours_bonus(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id=str(uuid.uuid4()),
            name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[
                    AvailabilityWindow(day=d, start_minutes=480, end_minutes=1200)
                    for d in [DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED, DayOfWeek.THU, DayOfWeek.FRI]
                ],
            ),
        )

        request = SchedulingRequest(
            id=str(uuid.uuid4()),
            room_id=str(uuid.uuid4()),
            title="Scored Meeting",
            participants=[participant],
            constraints=SchedulingConstraints(
                min_duration_minutes=30, preferred_duration_minutes=30, max_days_out=14,
            ),
            urgency=SchedulingUrgency.FLEXIBLE,
            created_at=1700000000000,
        )

        result = await scheduling_service.find_available_slots(request)
        assert result.success is True
        assert all(slot.score > 0 for slot in result.proposed_slots)


class TestDayIntersection:
    def _make_entry(self, entity_id: str, name: str, avail: Availability) -> dict[str, object]:
        """Helper to create participant entry tuples."""
        from elizaos_plugin_scheduling.service import _ParticipantEntry
        return _ParticipantEntry(
            Participant(entity_id=entity_id, name=name, availability=avail),
            avail,
        )

    def test_simple_overlap(self, scheduling_service: SchedulingService) -> None:
        a_avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=1020)],
        )
        b_avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=600, end_minutes=960)],
        )

        result = scheduling_service._find_day_intersection(
            [self._make_entry("a", "A", a_avail), self._make_entry("b", "B", b_avail)],
            DayOfWeek.MON, "2025-01-20", 30,
        )
        assert len(result) == 1
        assert result[0].start == 600
        assert result[0].end == 960

    def test_no_overlap(self, scheduling_service: SchedulingService) -> None:
        a_avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=600)],
        )
        b_avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=660, end_minutes=720)],
        )

        result = scheduling_service._find_day_intersection(
            [self._make_entry("a", "A", a_avail), self._make_entry("b", "B", b_avail)],
            DayOfWeek.MON, "2025-01-20", 30,
        )
        assert len(result) == 0

    def test_exception_unavailable(self, scheduling_service: SchedulingService) -> None:
        avail = Availability(
            time_zone="UTC",
            weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=1020)],
            exceptions=[AvailabilityException(date="2025-01-20", unavailable=True)],
        )

        result = scheduling_service._find_day_intersection(
            [self._make_entry("a", "A", avail)],
            DayOfWeek.MON, "2025-01-20", 30,
        )
        assert len(result) == 0


class TestMeetingCRUD:
    @pytest.mark.asyncio
    async def test_create_and_get_meeting(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot,
            MeetingLocation(type=LocationType.VIRTUAL, video_url="https://meet.example.com"),
        )

        assert meeting.id
        assert meeting.title == "Test Meeting"
        assert meeting.status == MeetingStatus.PROPOSED
        assert len(meeting.participants) == 2
        assert meeting.participants[0].role == ParticipantRole.ORGANIZER
        assert meeting.participants[1].role == ParticipantRole.REQUIRED

        retrieved = await scheduling_service.get_meeting(meeting.id)
        assert retrieved is not None
        assert retrieved.id == meeting.id

    @pytest.mark.asyncio
    async def test_confirm_participant(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        entity_id = sample_scheduling_request.participants[0].entity_id
        updated = await scheduling_service.confirm_participant(meeting.id, entity_id)
        p = next(p for p in updated.participants if p.entity_id == entity_id)
        assert p.confirmed is True
        assert p.confirmed_at is not None

    @pytest.mark.asyncio
    async def test_confirm_all_required_changes_status(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        for p in sample_scheduling_request.participants:
            meeting = await scheduling_service.confirm_participant(meeting.id, p.entity_id)

        assert meeting.status == MeetingStatus.CONFIRMED

    @pytest.mark.asyncio
    async def test_decline_required_triggers_rescheduling(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        entity_id = sample_scheduling_request.participants[0].entity_id
        updated = await scheduling_service.decline_participant(meeting.id, entity_id, "Conflict")
        assert updated.status == MeetingStatus.RESCHEDULING
        assert "Conflict" in (updated.cancellation_reason or "")

    @pytest.mark.asyncio
    async def test_cancel_meeting(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        cancelled = await scheduling_service.cancel_meeting(meeting.id, "No longer needed")
        assert cancelled.status == MeetingStatus.CANCELLED
        assert cancelled.cancellation_reason == "No longer needed"

    @pytest.mark.asyncio
    async def test_reschedule_meeting(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        new_slot = TimeSlot(start="2030-01-21T15:00:00Z", end="2030-01-21T16:00:00Z", time_zone="UTC")
        rescheduled = await scheduling_service.reschedule_meeting(meeting.id, new_slot, "Better time")

        assert rescheduled.slot.start == "2030-01-21T15:00:00Z"
        assert rescheduled.status == MeetingStatus.PROPOSED
        assert rescheduled.reschedule_count == 1
        assert all(not p.confirmed for p in rescheduled.participants)

    @pytest.mark.asyncio
    async def test_meeting_not_found(self, scheduling_service: SchedulingService) -> None:
        with pytest.raises(MeetingNotFoundError):
            await scheduling_service.confirm_participant("nonexistent", "entity-id")

    @pytest.mark.asyncio
    async def test_participant_not_found(
        self, scheduling_service: SchedulingService, sample_scheduling_request: SchedulingRequest
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        with pytest.raises(ParticipantNotFoundError):
            await scheduling_service.confirm_participant(meeting.id, "nonexistent-entity")


class TestCalendarInvites:
    @pytest.mark.asyncio
    async def test_generate_invite(
        self, scheduling_service: SchedulingService, sample_meeting: Meeting
    ) -> None:
        invite = scheduling_service.generate_calendar_invite(
            sample_meeting, "alice@example.com", "Alice"
        )

        assert invite.recipient_email == "alice@example.com"
        assert invite.recipient_name == "Alice"
        assert "BEGIN:VCALENDAR" in invite.ics
        assert invite.event.uid == sample_meeting.id
        assert invite.event.title == "Coffee Chat"

    @pytest.mark.asyncio
    async def test_send_invites(
        self, scheduling_service: SchedulingService, sample_meeting: Meeting
    ) -> None:
        invites = await scheduling_service.send_calendar_invites(sample_meeting)
        assert len(invites) == 2  # Alice and Bob both have emails


class TestFormatSlot:
    def test_format_slot(self, scheduling_service: SchedulingService) -> None:
        slot = TimeSlot(
            start="2025-01-20T15:00:00Z",
            end="2025-01-20T16:00:00Z",
            time_zone="America/New_York",
        )
        formatted = scheduling_service.format_slot(slot)
        assert "Mon" in formatted
        assert "Jan" in formatted
        assert "10:00" in formatted  # 15:00 UTC = 10:00 ET


class TestHealthCheck:
    @pytest.mark.asyncio
    async def test_healthy(self, scheduling_service: SchedulingService) -> None:
        result = await scheduling_service.health_check()
        assert result["healthy"] is True
        assert result["issues"] == []


# ============================================================================
# 1. REMINDER SYSTEM
# ============================================================================


class TestReminderSystem:
    @pytest.mark.asyncio
    async def test_schedule_reminders_creates_correct_types(
        self, scheduling_service: SchedulingService
    ) -> None:
        """phone -> SMS, email -> EMAIL, neither -> PUSH."""
        phone_user = Participant(
            entity_id="phone-user", name="Phone User",
            phone="+15551234567",
            availability=Availability(time_zone="UTC", weekly=[]),
        )
        email_user = Participant(
            entity_id="email-user", name="Email User",
            email="email@example.com",
            availability=Availability(time_zone="UTC", weekly=[]),
        )
        push_user = Participant(
            entity_id="push-user", name="Push User",
            availability=Availability(time_zone="UTC", weekly=[]),
        )

        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Reminder Type Test",
            participants=[phone_user, email_user, push_user],
            created_at=1700000000000,
        )

        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        reminders = await scheduling_service.schedule_reminders(meeting)

        # Default config: [1440, 120] = 2 reminder times x 3 participants = 6
        assert len(reminders) == 6

        phone_reminders = [r for r in reminders if r.participant_id == "phone-user"]
        email_reminders = [r for r in reminders if r.participant_id == "email-user"]
        push_reminders = [r for r in reminders if r.participant_id == "push-user"]

        assert len(phone_reminders) == 2
        assert all(r.type == ReminderType.SMS for r in phone_reminders)
        assert len(email_reminders) == 2
        assert all(r.type == ReminderType.EMAIL for r in email_reminders)
        assert len(push_reminders) == 2
        assert all(r.type == ReminderType.PUSH for r in push_reminders)

        # Verify each reminder has meaningful fields
        for r in reminders:
            assert r.id
            assert r.meeting_id == meeting.id
            assert r.status == ReminderStatus.PENDING
            assert "Reminder:" in r.message
            assert r.scheduled_for

    @pytest.mark.asyncio
    async def test_schedule_reminders_skips_past_times(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="alice", name="Alice", email="alice@example.com",
            availability=Availability(time_zone="UTC", weekly=[]),
        )
        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Past Meeting", participants=[participant], created_at=0,
        )

        past = datetime.now(timezone.utc) - timedelta(hours=1)
        slot = TimeSlot(
            start=past.isoformat().replace("+00:00", "Z"),
            end=(past + timedelta(hours=1)).isoformat().replace("+00:00", "Z"),
            time_zone="UTC",
        )
        meeting = await scheduling_service.create_meeting(
            request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        reminders = await scheduling_service.schedule_reminders(meeting)
        assert len(reminders) == 0

    @pytest.mark.asyncio
    async def test_get_due_reminders_returns_only_due(
        self, scheduling_service: SchedulingService
    ) -> None:
        storage = get_reminder_storage(scheduling_service.runtime)
        past = datetime.now(timezone.utc) - timedelta(hours=1)
        future = datetime.now(timezone.utc) + timedelta(hours=24)

        due_reminder = Reminder(
            id="due-1", meeting_id="m1", participant_id="p1",
            scheduled_for=past.isoformat().replace("+00:00", "Z"),
            type=ReminderType.EMAIL, message="Due reminder",
            status=ReminderStatus.PENDING, created_at=0,
        )
        await storage.save(due_reminder)

        future_reminder = Reminder(
            id="future-1", meeting_id="m1", participant_id="p1",
            scheduled_for=future.isoformat().replace("+00:00", "Z"),
            type=ReminderType.EMAIL, message="Future reminder",
            status=ReminderStatus.PENDING, created_at=0,
        )
        await storage.save(future_reminder)

        sent_reminder = Reminder(
            id="sent-1", meeting_id="m1", participant_id="p1",
            scheduled_for=past.isoformat().replace("+00:00", "Z"),
            type=ReminderType.EMAIL, message="Already sent",
            status=ReminderStatus.SENT, created_at=0,
        )
        await storage.save(sent_reminder)

        due = await scheduling_service.get_due_reminders()
        assert len(due) == 1
        assert due[0].id == "due-1"
        assert due[0].status == ReminderStatus.PENDING

    @pytest.mark.asyncio
    async def test_mark_reminder_sent_sets_flag(
        self, scheduling_service: SchedulingService
    ) -> None:
        storage = get_reminder_storage(scheduling_service.runtime)

        reminder = Reminder(
            id="mark-1", meeting_id="m1", participant_id="p1",
            scheduled_for="2030-01-20T15:00:00Z",
            type=ReminderType.EMAIL, message="Test reminder",
            status=ReminderStatus.PENDING, created_at=0,
        )
        await storage.save(reminder)

        await scheduling_service.mark_reminder_sent("mark-1")

        updated = await storage.get("mark-1")
        assert updated is not None
        assert updated.status == ReminderStatus.SENT
        assert updated.sent_at is not None
        assert updated.sent_at > 0

    @pytest.mark.asyncio
    async def test_cancel_reminders_removes_meeting_reminders(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="alice", name="Alice", email="alice@example.com",
            availability=Availability(time_zone="UTC", weekly=[]),
        )
        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Cancel Reminders Test", participants=[participant], created_at=0,
        )

        slot = TimeSlot(start="2030-06-20T15:00:00Z", end="2030-06-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        reminders = await scheduling_service.schedule_reminders(meeting)
        assert len(reminders) > 0

        await scheduling_service.cancel_reminders(meeting.id)

        storage = get_reminder_storage(scheduling_service.runtime)
        for reminder in reminders:
            updated = await storage.get(reminder.id)
            assert updated is not None
            assert updated.status == ReminderStatus.CANCELLED


# ============================================================================
# 2. SLOT SCORING (verify actual scores, not just success booleans)
# ============================================================================


class TestSlotScoring:
    def test_score_slot_preferred_times_bonus(
        self, scheduling_service: SchedulingService
    ) -> None:
        """preferred_times match should add +20."""
        # 10:00 UTC = 600 min = "morning" (< 720)
        slot = TimeSlot(start="2030-01-21T10:00:00Z", end="2030-01-21T10:30:00Z", time_zone="UTC")
        request = SchedulingRequest(
            id="req-1", room_id="room-1", title="Test", participants=[],
            constraints=SchedulingConstraints(preferred_times=["morning"]),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = scheduling_service._score_slot(slot, request)

        assert isinstance(result, ProposedSlot)
        assert "Preferred time (morning)" in result.reasons
        assert "Standard business hours" in result.reasons
        # 100 base + 20 (preferred time) + 10 (business hours: 600 in 600..960) = 130
        assert result.score == pytest.approx(130.0)

    def test_score_slot_preferred_days_bonus(
        self, scheduling_service: SchedulingService
    ) -> None:
        """preferred_days match should add +15."""
        # 2030-01-21 is a Monday, 14:00 UTC = 840 min = afternoon, business hours
        slot = TimeSlot(start="2030-01-21T14:00:00Z", end="2030-01-21T14:30:00Z", time_zone="UTC")
        request = SchedulingRequest(
            id="req-1", room_id="room-1", title="Test", participants=[],
            constraints=SchedulingConstraints(preferred_days=[DayOfWeek.MON]),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = scheduling_service._score_slot(slot, request)

        assert "Preferred day (mon)" in result.reasons
        assert "Standard business hours" in result.reasons
        # 100 + 15 (preferred day) + 10 (business hours) = 125
        assert result.score == pytest.approx(125.0)

    def test_score_slot_urgency_modifiers(
        self, scheduling_service: SchedulingService
    ) -> None:
        """URGENT -10*days, SOON -5*days, FLEXIBLE no penalty."""
        # Use a slot ~12 hours from now for meaningful urgency differences
        future = datetime.now(timezone.utc) + timedelta(hours=12)
        slot_start = future.replace(minute=0, second=0, microsecond=0)
        slot_end = slot_start + timedelta(minutes=30)

        slot = TimeSlot(
            start=slot_start.isoformat().replace("+00:00", "Z"),
            end=slot_end.isoformat().replace("+00:00", "Z"),
            time_zone="UTC",
        )

        def make_request(urgency: SchedulingUrgency) -> SchedulingRequest:
            return SchedulingRequest(
                id="req-1", room_id="room-1", title="Test",
                participants=[], urgency=urgency, created_at=0,
            )

        flexible = scheduling_service._score_slot(slot, make_request(SchedulingUrgency.FLEXIBLE))
        soon = scheduling_service._score_slot(slot, make_request(SchedulingUrgency.SOON))
        urgent = scheduling_service._score_slot(slot, make_request(SchedulingUrgency.URGENT))

        # URGENT < SOON < FLEXIBLE for the same slot
        assert urgent.score < soon.score < flexible.score

        # URGENT penalty = 2 * SOON penalty (both relative to FLEXIBLE)
        urgent_penalty = flexible.score - urgent.score
        soon_penalty = flexible.score - soon.score
        assert urgent_penalty == pytest.approx(soon_penalty * 2, abs=1.0)

    def test_score_slot_early_late_penalties(
        self, scheduling_service: SchedulingService
    ) -> None:
        """Before 9am -> -15, after 6pm -> -10."""
        request = SchedulingRequest(
            id="req-1", room_id="room-1", title="Test", participants=[],
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        # Early morning: 7am UTC = 420 min (< 540)
        early_slot = TimeSlot(
            start="2030-01-21T07:00:00Z", end="2030-01-21T07:30:00Z", time_zone="UTC",
        )
        early = scheduling_service._score_slot(early_slot, request)
        assert "Early morning" in early.concerns
        # 100 - 15 (early), no business hours (420 < 600) = 85
        assert early.score == pytest.approx(85.0)

        # Late evening: 7pm UTC = 1140 min (> 1080)
        late_slot = TimeSlot(
            start="2030-01-21T19:00:00Z", end="2030-01-21T19:30:00Z", time_zone="UTC",
        )
        late = scheduling_service._score_slot(late_slot, request)
        assert "Evening time" in late.concerns
        # 100 - 10 (late), no business hours (1140 > 960) = 90
        assert late.score == pytest.approx(90.0)

    def test_score_slot_business_hours_bonus(
        self, scheduling_service: SchedulingService
    ) -> None:
        """Between 10am-4pm (600-960 min) should get +10."""
        request = SchedulingRequest(
            id="req-1", room_id="room-1", title="Test", participants=[],
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        # Business hours: 11am UTC = 660 min, between 600-960
        biz_slot = TimeSlot(
            start="2030-01-21T11:00:00Z", end="2030-01-21T11:30:00Z", time_zone="UTC",
        )
        biz = scheduling_service._score_slot(biz_slot, request)
        assert "Standard business hours" in biz.reasons
        # 100 + 10 = 110
        assert biz.score == pytest.approx(110.0)

        # Outside business hours: 5pm UTC = 1020 min (> 960)
        non_biz_slot = TimeSlot(
            start="2030-01-21T17:00:00Z", end="2030-01-21T17:30:00Z", time_zone="UTC",
        )
        non_biz = scheduling_service._score_slot(non_biz_slot, request)
        assert "Standard business hours" not in non_biz.reasons
        # 100 (no bonuses, no penalties: 1020 is between 540 and 1080)
        assert non_biz.score == pytest.approx(100.0)


# ============================================================================
# 3. BOUNDARY CONDITIONS
# ============================================================================


class TestBoundaryConditions:
    @pytest.mark.asyncio
    async def test_find_slots_cross_timezone(
        self, scheduling_service: SchedulingService
    ) -> None:
        """Participants in Asia/Tokyo + US/Pacific should not crash."""
        tokyo = Participant(
            entity_id="tokyo-user", name="Tokyo User",
            availability=Availability(
                time_zone="Asia/Tokyo",
                weekly=[
                    AvailabilityWindow(day=d, start_minutes=540, end_minutes=1020)
                    for d in [DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED,
                              DayOfWeek.THU, DayOfWeek.FRI]
                ],
            ),
        )
        pacific = Participant(
            entity_id="pacific-user", name="Pacific User",
            availability=Availability(
                time_zone="US/Pacific",
                weekly=[
                    AvailabilityWindow(day=d, start_minutes=540, end_minutes=1020)
                    for d in [DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED,
                              DayOfWeek.THU, DayOfWeek.FRI]
                ],
            ),
        )

        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Cross-Timezone Meeting",
            participants=[tokyo, pacific],
            constraints=SchedulingConstraints(
                min_duration_minutes=30, preferred_duration_minutes=30,
            ),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = await scheduling_service.find_available_slots(request)
        assert isinstance(result, SchedulingResult)
        # Verify no crash; reference tz comes from first participant
        if result.success:
            for slot in result.proposed_slots:
                assert slot.slot.time_zone == "Asia/Tokyo"
                assert slot.score > 0

    @pytest.mark.asyncio
    async def test_find_slots_empty_weekly_availability(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="no-avail", name="No Availability",
            availability=Availability(time_zone="UTC", weekly=[]),
        )
        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Empty Avail", participants=[participant],
            constraints=SchedulingConstraints(min_duration_minutes=30),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = await scheduling_service.find_available_slots(request)
        assert result.success is False
        assert result.proposed_slots == []

    @pytest.mark.asyncio
    async def test_find_slots_zero_duration_preference(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="alice", name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[
                    AvailabilityWindow(day=d, start_minutes=540, end_minutes=600)
                    for d in [DayOfWeek.MON, DayOfWeek.TUE, DayOfWeek.WED,
                              DayOfWeek.THU, DayOfWeek.FRI]
                ],
            ),
        )
        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Zero Duration", participants=[participant],
            constraints=SchedulingConstraints(
                min_duration_minutes=0, preferred_duration_minutes=0,
            ),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = await scheduling_service.find_available_slots(request)
        # Zero duration means every 30-min boundary produces a slot
        assert result.success is True
        assert len(result.proposed_slots) > 0
        for slot in result.proposed_slots:
            assert slot.slot.start  # valid ISO string
            assert slot.slot.end

    def test_meetings_spanning_midnight(
        self, scheduling_service: SchedulingService
    ) -> None:
        slot = TimeSlot(
            start="2030-01-20T23:00:00Z",
            end="2030-01-21T01:00:00Z",
            time_zone="UTC",
        )
        formatted = scheduling_service.format_slot(slot)
        assert "11:00 PM" in formatted
        assert "1:00 AM" in formatted

    def test_date_from_minutes_at_midnight_boundary(self) -> None:
        """1440 minutes (24:00) should raise ValueError."""
        with pytest.raises(ValueError):
            _date_from_minutes("2030-01-20", 1440, "UTC")

    def test_date_from_minutes_overflow(self) -> None:
        """Minutes > 1440 should raise ValueError."""
        with pytest.raises(ValueError):
            _date_from_minutes("2030-01-20", 1500, "UTC")

    @pytest.mark.asyncio
    async def test_exact_30_minute_buffer_from_now(
        self, scheduling_service: SchedulingService
    ) -> None:
        """Day-0 slots within 30 min of now should be excluded."""
        now = datetime.now(timezone.utc)
        today_day = _get_day_of_week(now, "UTC")

        participant = Participant(
            entity_id="alice", name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[AvailabilityWindow(day=today_day, start_minutes=0, end_minutes=1439)],
            ),
        )
        request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Buffer Test", participants=[participant],
            constraints=SchedulingConstraints(
                min_duration_minutes=30, preferred_duration_minutes=30,
                max_days_out=1,
            ),
            urgency=SchedulingUrgency.FLEXIBLE, created_at=0,
        )

        result = await scheduling_service.find_available_slots(request)
        if result.success:
            for proposed in result.proposed_slots:
                slot_start = datetime.fromisoformat(
                    proposed.slot.start.replace("Z", "+00:00")
                )
                delta_minutes = (slot_start - now).total_seconds() / 60
                assert delta_minutes >= 29, (
                    f"Slot starts only {delta_minutes:.1f} min from now, expected >= 29"
                )


# ============================================================================
# 4. UNTESTED FUNCTIONS
# ============================================================================


class TestUntestedFunctions:
    @pytest.mark.asyncio
    async def test_get_meetings_for_room(
        self, scheduling_service: SchedulingService,
        sample_scheduling_request: SchedulingRequest,
    ) -> None:
        room_id = sample_scheduling_request.room_id
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting1 = await scheduling_service.create_meeting(
            sample_scheduling_request, slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        other_request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id="other-room",
            title="Other Meeting",
            participants=sample_scheduling_request.participants, created_at=0,
        )
        slot2 = TimeSlot(start="2030-01-21T15:00:00Z", end="2030-01-21T16:00:00Z", time_zone="UTC")
        await scheduling_service.create_meeting(
            other_request, slot2, MeetingLocation(type=LocationType.VIRTUAL),
        )

        room_meetings = await scheduling_service.get_meetings_for_room(room_id)
        assert len(room_meetings) == 1
        assert room_meetings[0].id == meeting1.id
        assert room_meetings[0].room_id == room_id
        assert room_meetings[0].title == "Test Meeting"

    @pytest.mark.asyncio
    async def test_get_upcoming_meetings(
        self, scheduling_service: SchedulingService,
        sample_scheduling_request: SchedulingRequest,
    ) -> None:
        entity_id = sample_scheduling_request.participants[0].entity_id

        # Future meeting
        future_slot = TimeSlot(
            start="2030-06-20T15:00:00Z", end="2030-06-20T16:00:00Z", time_zone="UTC",
        )
        future_meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, future_slot,
            MeetingLocation(type=LocationType.VIRTUAL),
        )

        # Past meeting
        past = datetime.now(timezone.utc) - timedelta(days=1)
        past_request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Past Meeting",
            participants=sample_scheduling_request.participants, created_at=0,
        )
        past_slot = TimeSlot(
            start=past.isoformat().replace("+00:00", "Z"),
            end=(past + timedelta(hours=1)).isoformat().replace("+00:00", "Z"),
            time_zone="UTC",
        )
        await scheduling_service.create_meeting(
            past_request, past_slot, MeetingLocation(type=LocationType.VIRTUAL),
        )

        # Cancelled future meeting
        cancelled_request = SchedulingRequest(
            id=str(uuid.uuid4()), room_id=str(uuid.uuid4()),
            title="Cancelled Meeting",
            participants=sample_scheduling_request.participants, created_at=0,
        )
        cancelled_slot = TimeSlot(
            start="2030-07-20T15:00:00Z", end="2030-07-20T16:00:00Z", time_zone="UTC",
        )
        cancelled_meeting = await scheduling_service.create_meeting(
            cancelled_request, cancelled_slot,
            MeetingLocation(type=LocationType.VIRTUAL),
        )
        await scheduling_service.cancel_meeting(cancelled_meeting.id, "Testing")

        upcoming = await scheduling_service.get_upcoming_meetings(entity_id)
        assert len(upcoming) == 1
        assert upcoming[0].id == future_meeting.id
        assert upcoming[0].slot.start == "2030-06-20T15:00:00Z"

    @pytest.mark.asyncio
    async def test_update_meeting_status(
        self, scheduling_service: SchedulingService,
        sample_scheduling_request: SchedulingRequest,
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot,
            MeetingLocation(type=LocationType.VIRTUAL),
        )
        assert meeting.status == MeetingStatus.PROPOSED

        updated = await scheduling_service.update_meeting_status(
            meeting.id, MeetingStatus.COMPLETED,
        )
        assert updated.status == MeetingStatus.COMPLETED
        assert updated.id == meeting.id

        retrieved = await scheduling_service.get_meeting(meeting.id)
        assert retrieved is not None
        assert retrieved.status == MeetingStatus.COMPLETED

    @pytest.mark.asyncio
    async def test_create_scheduling_request(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="alice", name="Alice", email="alice@example.com",
            availability=Availability(time_zone="UTC", weekly=[]),
        )

        request = await scheduling_service.create_scheduling_request(
            room_id="room-1",
            title="Requested Meeting",
            participants=[participant],
            constraints=SchedulingConstraints(min_duration_minutes=45),
            description="Test description",
            urgency=SchedulingUrgency.SOON,
        )

        assert request.id  # UUID was generated
        assert request.room_id == "room-1"
        assert request.title == "Requested Meeting"
        assert request.description == "Test description"
        assert request.urgency == SchedulingUrgency.SOON
        assert request.constraints.min_duration_minutes == 45
        assert len(request.participants) == 1
        assert request.participants[0].entity_id == "alice"
        assert request.created_at > 0
        assert request.max_proposals == scheduling_service.config.max_proposals

    @pytest.mark.asyncio
    async def test_start_and_stop_service(self, mock_runtime) -> None:
        service = await SchedulingService.start(mock_runtime)
        assert service is not None
        assert service.runtime is mock_runtime

        health = await service.health_check()
        assert health["healthy"] is True
        assert health["issues"] == []

        # stop should not raise
        await service.stop()

    @pytest.mark.asyncio
    async def test_decline_optional_participant_no_reschedule(
        self, scheduling_service: SchedulingService,
        sample_scheduling_request: SchedulingRequest,
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot,
            MeetingLocation(type=LocationType.VIRTUAL),
        )

        # Manually set one participant to OPTIONAL and persist
        meeting.participants[1].role = ParticipantRole.OPTIONAL
        await get_meeting_storage(scheduling_service.runtime).save(meeting)

        # Decline the optional participant
        updated = await scheduling_service.decline_participant(
            meeting.id, meeting.participants[1].entity_id, "Can't make it",
        )

        # Status should NOT change to RESCHEDULING
        assert updated.status == MeetingStatus.PROPOSED
        optional_p = next(
            p for p in updated.participants
            if p.entity_id == meeting.participants[1].entity_id
        )
        assert optional_p.confirmed is False
        assert optional_p.decline_reason == "Can't make it"

    @pytest.mark.asyncio
    async def test_generate_calendar_invite_in_person_location(
        self, scheduling_service: SchedulingService
    ) -> None:
        meeting = Meeting(
            id="meeting-ip-1",
            request_id="req-1",
            room_id="room-1",
            title="In-Person Lunch",
            slot=TimeSlot(
                start="2030-01-20T12:00:00Z",
                end="2030-01-20T13:00:00Z",
                time_zone="UTC",
            ),
            location=MeetingLocation(
                type=LocationType.IN_PERSON,
                name="The Restaurant",
                address="123 Main St",
            ),
            participants=[
                MeetingParticipant(
                    entity_id="alice", name="Alice",
                    email="alice@example.com",
                    role=ParticipantRole.ORGANIZER,
                ),
            ],
            status=MeetingStatus.PROPOSED,
            created_at=1700000000000, updated_at=1700000000000,
        )

        invite = scheduling_service.generate_calendar_invite(
            meeting, "alice@example.com", "Alice",
        )

        assert invite.event.location == "The Restaurant, 123 Main St"
        assert invite.event.title == "In-Person Lunch"
        assert "BEGIN:VCALENDAR" in invite.ics
        assert invite.event.url is None  # No video URL for in-person


# ============================================================================
# 5. CONCURRENT
# ============================================================================


class TestConcurrent:
    @pytest.mark.asyncio
    async def test_concurrent_meeting_creation(
        self, scheduling_service: SchedulingService
    ) -> None:
        participant = Participant(
            entity_id="alice", name="Alice",
            availability=Availability(
                time_zone="UTC",
                weekly=[AvailabilityWindow(day=DayOfWeek.MON, start_minutes=540, end_minutes=1020)],
            ),
        )

        request1 = SchedulingRequest(
            id=str(uuid.uuid4()), room_id="room-1",
            title="Meeting 1", participants=[participant], created_at=0,
        )
        request2 = SchedulingRequest(
            id=str(uuid.uuid4()), room_id="room-1",
            title="Meeting 2", participants=[participant], created_at=0,
        )

        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        location = MeetingLocation(type=LocationType.VIRTUAL)

        meeting1, meeting2 = await asyncio.gather(
            scheduling_service.create_meeting(request1, slot, location),
            scheduling_service.create_meeting(request2, slot, location),
        )

        assert meeting1.id != meeting2.id
        assert meeting1.title == "Meeting 1"
        assert meeting2.title == "Meeting 2"
        assert meeting1.status == MeetingStatus.PROPOSED
        assert meeting2.status == MeetingStatus.PROPOSED

    @pytest.mark.asyncio
    async def test_concurrent_confirm_and_decline(
        self, scheduling_service: SchedulingService,
        sample_scheduling_request: SchedulingRequest,
    ) -> None:
        slot = TimeSlot(start="2030-01-20T15:00:00Z", end="2030-01-20T16:00:00Z", time_zone="UTC")
        meeting = await scheduling_service.create_meeting(
            sample_scheduling_request, slot,
            MeetingLocation(type=LocationType.VIRTUAL),
        )

        alice_id = sample_scheduling_request.participants[0].entity_id
        bob_id = sample_scheduling_request.participants[1].entity_id

        results = await asyncio.gather(
            scheduling_service.confirm_participant(meeting.id, alice_id),
            scheduling_service.decline_participant(meeting.id, bob_id, "Busy"),
            return_exceptions=True,
        )

        # Both operations should complete without crashes
        for result in results:
            assert not isinstance(result, Exception), f"Unexpected exception: {result}"

        final = await scheduling_service.get_meeting(meeting.id)
        assert final is not None
        # The final state reflects both operations (order depends on event loop)
        assert final.status in (
            MeetingStatus.PROPOSED,
            MeetingStatus.RESCHEDULING,
            MeetingStatus.CONFIRMED,
        )
