"""
Service layer for appointment slots, booking, and lifecycle management.
"""

import math
from datetime import UTC, date, datetime, time, timedelta
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import NotFoundException, ValidationException
from app.core.pagination import PaginationParams
from app.core.responses import PaginatedResponse, PaginationMeta
from app.models.appointment import (
    Appointment,
    AppointmentStatus,
    BookingChannel,
    LabHoliday,
)
from app.repositories.appointment_repository import AppointmentRepository
from app.repositories.lab_repository import LabRepository
from app.repositories.patient_repository import PatientRepository
from app.repositories.schedule_repository import ScheduleRepository
from app.schemas.appointment import (
    AppointmentCreate,
    AppointmentResponse,
    AppointmentSlotCreate,
    AppointmentSlotResponse,
    AppointmentSlotUpdate,
    AppointmentUpdate,
    QuickAppointmentCreate,
)


class AppointmentService:
    """Business logic for lab appointments and scheduling."""

    def __init__(
        self,
        appointment_repo: AppointmentRepository | None = None,
        patient_repo: PatientRepository | None = None,
        schedule_repo: ScheduleRepository | None = None,
    ) -> None:
        self.repo = appointment_repo or AppointmentRepository()
        self.patient_repo = patient_repo or PatientRepository()
        self.schedule_repo = schedule_repo or ScheduleRepository()

    # ── Slot Management ───────────────────────────────────────────────────────

    async def list_slots(
        self,
        db: AsyncSession,
        pagination: PaginationParams,
        lab_id: UUID | None = None,
        is_active: bool | None = None,
    ) -> PaginatedResponse[AppointmentSlotResponse]:
        """Fetch paginated appointment slots."""
        slots, total = await self.repo.list_slots(
            db,
            limit=pagination.limit,
            offset=pagination.offset,
            lab_id=lab_id,
            is_active=is_active,
        )
        items = [
            AppointmentSlotResponse(
                id=s.id,
                lab_id=s.lab_id,
                start_at=s.start_at,
                end_at=s.end_at,
                capacity=s.capacity,
                booked_count=s.booked_count,
                is_active=s.is_active,
                is_full=s.is_full,
                available_slots=s.available_slots,
                created_at=s.created_at,
                updated_at=s.updated_at,
            )
            for s in slots
        ]
        pages = math.ceil(total / pagination.limit) if total > 0 else 1
        return PaginatedResponse(
            data=items,
            meta=PaginationMeta(
                total=total,
                page=pagination.page,
                limit=pagination.limit,
                pages=pages,
            ),
        )

    async def get_slot(
        self, db: AsyncSession, slot_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentSlotResponse:
        """Fetch a specific appointment slot."""
        slot = await self.repo.get_slot_by_id(db, slot_id, lab_id=lab_id)
        if not slot:
            raise NotFoundException(message=f"Appointment slot with id '{slot_id}' was not found.")
        return AppointmentSlotResponse(
            id=slot.id,
            lab_id=slot.lab_id,
            start_at=slot.start_at,
            end_at=slot.end_at,
            capacity=slot.capacity,
            booked_count=slot.booked_count,
            is_active=slot.is_active,
            is_full=slot.is_full,
            available_slots=slot.available_slots,
            created_at=slot.created_at,
            updated_at=slot.updated_at,
        )

    async def create_slot(self, db: AsyncSession, data: AppointmentSlotCreate) -> AppointmentSlotResponse:
        """Create a new appointment window."""
        if data.lab_id is None:
            data.lab_id = await LabRepository().get_default_lab_id(db)

        if data.end_at <= data.start_at:
            raise ValidationException(message="Slot end time must be after start time.")

        slot = await self.repo.create_slot(db, data)
        return AppointmentSlotResponse(
            id=slot.id,
            lab_id=slot.lab_id,
            start_at=slot.start_at,
            end_at=slot.end_at,
            capacity=slot.capacity,
            booked_count=slot.booked_count,
            is_active=slot.is_active,
            is_full=slot.is_full,
            available_slots=slot.available_slots,
            created_at=slot.created_at,
            updated_at=slot.updated_at,
        )

    async def update_slot(
        self, db: AsyncSession, slot_id: UUID, data: AppointmentSlotUpdate, lab_id: UUID | None = None
    ) -> AppointmentSlotResponse:
        """Update slot capacity or timings."""
        slot = await self.repo.get_slot_by_id(db, slot_id, lab_id=lab_id)
        if not slot:
            raise NotFoundException(message=f"Appointment slot with id '{slot_id}' was not found.")

        start = data.start_at or slot.start_at
        end = data.end_at or slot.end_at
        if end <= start:
            raise ValidationException(message="Slot end time must be after start time.")

        updated = await self.repo.update_slot(db, slot, data)
        return AppointmentSlotResponse(
            id=updated.id,
            lab_id=updated.lab_id,
            start_at=updated.start_at,
            end_at=updated.end_at,
            capacity=updated.capacity,
            booked_count=updated.booked_count,
            is_active=updated.is_active,
            is_full=updated.is_full,
            available_slots=updated.available_slots,
            created_at=updated.created_at,
            updated_at=updated.updated_at,
        )

    async def delete_slot(self, db: AsyncSession, slot_id: UUID, lab_id: UUID | None = None) -> None:
        """Soft delete a slot."""
        slot = await self.repo.get_slot_by_id(db, slot_id, lab_id=lab_id)
        if not slot:
            raise NotFoundException(message=f"Appointment slot with id '{slot_id}' was not found.")
        await self.repo.delete_slot(db, slot)

    async def get_holiday_for_date(
        self,
        db: AsyncSession,
        target_date: date,
        lab_id: UUID | None = None,
    ) -> LabHoliday | None:
        """Check if target_date has an active holiday or closure."""
        if lab_id is None:
            lab_id = await LabRepository().get_default_lab_id(db)
        return await self.schedule_repo.find_holiday_for_date(db, lab_id, target_date)

    async def ensure_day_slots(
        self,
        db: AsyncSession,
        target_date: date,
        lab_id: UUID | None = None,
        capacity: int | None = None,
    ) -> list[AppointmentSlotResponse]:
        """Create time windows for a calendar day based on lab schedule and holiday rules."""
        if lab_id is None:
            lab_id = await LabRepository().get_default_lab_id(db)

        # 1. Check for active holiday / closure
        holiday = await self.schedule_repo.find_holiday_for_date(db, lab_id, target_date)
        if holiday and holiday.is_closed:
            return []

        # 2. Check lab operating schedule
        config = await self.schedule_repo.get_or_create_config(db, lab_id)
        if not config.is_active:
            return []

        active_days = [int(d.strip()) for d in config.operating_days.split(",") if d.strip().isdigit()]
        if target_date.weekday() not in active_days:
            return []

        from zoneinfo import ZoneInfo

        local_tz = ZoneInfo("Asia/Kathmandu")

        slot_cap = capacity if capacity is not None else config.slot_capacity

        # Calculate exact opening and closing boundaries for target_date in local timezone and UTC
        open_parts = [int(p) for p in config.open_time.split(":")]
        close_parts = [int(p) for p in config.close_time.split(":")]
        opening_time_local = datetime.combine(
            target_date, time(open_parts[0], open_parts[1]), tzinfo=local_tz
        )
        closing_time_local = datetime.combine(
            target_date, time(close_parts[0], close_parts[1]), tzinfo=local_tz
        )
        opening_utc = opening_time_local.astimezone(UTC)
        closing_utc = closing_time_local.astimezone(UTC)
        step = timedelta(minutes=config.slot_duration_minutes)

        # Expected slots on the grid between opening and closing
        expected_slots: list[tuple[datetime, datetime]] = []
        cur = opening_time_local
        while cur + step <= closing_time_local:
            expected_slots.append((cur.astimezone(UTC), (cur + step).astimezone(UTC)))
            cur += step

        expected_starts = {ws.replace(microsecond=0) for ws, _ in expected_slots}

        # Query all existing slots within the full local day to catch and purge
        # any out-of-bounds slots (e.g. 12am)
        local_day_start = datetime.combine(target_date, datetime.min.time(), tzinfo=local_tz)
        local_day_end = local_day_start + timedelta(days=1)
        day_start = local_day_start.astimezone(UTC)
        day_end = local_day_end.astimezone(UTC)

        existing, _ = await self.repo.list_slots_in_range(
            db, lab_id=lab_id, start=day_start, end=day_end, limit=200
        )

        active_starts: set[datetime] = set()
        for s in existing:
            s_start = s.start_at.replace(microsecond=0)
            if s.booked_count == 0:
                # If slot is outside operating hours (e.g. 12am midnight) or has mismatched duration, prune it
                if s_start not in expected_starts or (s.end_at - s.start_at) != step:
                    s.deleted_at = datetime.now(UTC)
                    s.is_active = False
                else:
                    if s.capacity != slot_cap:
                        s.capacity = slot_cap
                    active_starts.add(s_start)
            else:
                active_starts.add(s_start)

        await db.flush()

        # Generate any missing windows within the operating hours
        for win_start, win_end in expected_slots:
            key = win_start.replace(microsecond=0)
            if key in active_starts:
                continue

            payload = AppointmentSlotCreate(
                lab_id=lab_id,
                start_at=win_start,
                end_at=win_end,
                capacity=slot_cap,
                is_active=True,
            )
            await self.create_slot(db, payload)

        # Return only the slots within the configured operating hours
        refreshed, _ = await self.repo.list_slots_in_range(
            db, lab_id=lab_id, start=opening_utc, end=closing_utc, limit=200
        )
        return [
            AppointmentSlotResponse(
                id=s.id,
                lab_id=s.lab_id,
                start_at=s.start_at,
                end_at=s.end_at,
                capacity=s.capacity,
                booked_count=s.booked_count,
                is_active=s.is_active,
                is_full=s.is_full,
                available_slots=s.available_slots,
                created_at=s.created_at,
                updated_at=s.updated_at,
            )
            for s in refreshed
            if s.is_active
        ]

    # ── Appointment Operations ────────────────────────────────────────────────

    def _to_response(self, apt: Appointment) -> AppointmentResponse:
        scheduled = apt.slot.start_at if apt.slot else apt.created_at
        return AppointmentResponse(
            id=apt.id,
            lab_id=apt.lab_id,
            patient_id=apt.patient_id,
            slot_id=apt.slot_id,
            visit_order_id=apt.visit_order_id,
            status=apt.status,
            booked_via=apt.booked_via,
            contact_name=apt.contact_name,
            contact_phone=apt.contact_phone,
            notes=apt.notes,
            patient_name=apt.patient.name if apt.patient else None,
            patient_phone=apt.patient.phone if apt.patient else None,
            scheduled_at=scheduled,
            created_at=apt.created_at,
            updated_at=apt.updated_at,
        )

    async def get_active_appointment_for_patient(
        self,
        db: AsyncSession,
        patient_id: UUID,
        lab_id: UUID | None = None,
    ) -> AppointmentResponse | None:
        """Return the patient's open appointment, if any."""
        active = await self.repo.find_active_appointment_for_patient(db, patient_id, lab_id=lab_id)
        if not active:
            return None
        return self._to_response(active)

    async def list_appointments(
        self,
        db: AsyncSession,
        pagination: PaginationParams,
        lab_id: UUID | None = None,
        status: AppointmentStatus | None = None,
        patient_id: UUID | None = None,
        slot_id: UUID | None = None,
        active_only: bool = False,
        target_date: date | None = None,
    ) -> PaginatedResponse[AppointmentResponse]:
        """Fetch paginated appointments."""
        apts, total = await self.repo.list_appointments(
            db,
            limit=pagination.limit,
            offset=pagination.offset,
            lab_id=lab_id,
            status=status,
            patient_id=patient_id,
            slot_id=slot_id,
            active_only=active_only,
            target_date=target_date,
        )
        items = [self._to_response(a) for a in apts]
        pages = math.ceil(total / pagination.limit) if total > 0 else 1
        return PaginatedResponse(
            data=items,
            meta=PaginationMeta(
                total=total,
                page=pagination.page,
                limit=pagination.limit,
                pages=pages,
            ),
        )

    async def get_appointment(
        self, db: AsyncSession, appointment_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentResponse:
        """Fetch appointment details."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        return self._to_response(apt)

    async def create_appointment(self, db: AsyncSession, data: AppointmentCreate) -> AppointmentResponse:
        """Book a new appointment with slot capacity checking."""
        if data.lab_id is None:
            data.lab_id = await LabRepository().get_default_lab_id(db)

        patient = await self.patient_repo.get_by_id(db, data.patient_id, lab_id=data.lab_id)
        if not patient:
            raise NotFoundException(message=f"Patient with id '{data.patient_id}' was not found.")

        active = await self.repo.find_active_appointment_for_patient(db, data.patient_id, data.lab_id)
        if active:
            raise ValidationException(
                message=(
                    "This patient already has an active appointment. "
                    "Reschedule or cancel it before booking again."
                )
            )

        initial_status = AppointmentStatus.PENDING
        if data.slot_id:
            slot = await self.repo.get_slot_for_update(db, data.slot_id, lab_id=data.lab_id)
            if not slot:
                raise NotFoundException(message=f"Appointment slot with id '{data.slot_id}' was not found.")
            if not slot.is_active:
                raise ValidationException(message="This appointment slot is currently closed.")
            if slot.booked_count >= slot.capacity:
                raise ValidationException(message="This appointment slot is fully booked.")

            slot.booked_count += 1
            if data.booked_via in (BookingChannel.PHONE, BookingChannel.WALK_IN):
                initial_status = AppointmentStatus.CONFIRMED
        else:
            # Walk-in without a slot
            initial_status = AppointmentStatus.CHECKED_IN

        apt = await self.repo.create(db, data, initial_status=initial_status)
        # Reload with relations
        loaded = await self.repo.get_by_id(db, apt.id, lab_id=data.lab_id)
        return self._to_response(loaded or apt)

    async def update_appointment(
        self,
        db: AsyncSession,
        appointment_id: UUID,
        data: AppointmentUpdate,
        lab_id: UUID | None = None,
    ) -> AppointmentResponse:
        """Update appointment notes or contact."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")

        updated = await self.repo.update(db, apt, data)
        return self._to_response(updated)

    async def reschedule_appointment(
        self,
        db: AsyncSession,
        appointment_id: UUID,
        new_slot_id: UUID,
        lab_id: UUID | None = None,
    ) -> AppointmentResponse:
        """Move an appointment to a different slot (releases old capacity, books new)."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        if apt.status in (
            AppointmentStatus.COMPLETED,
            AppointmentStatus.CANCELLED,
            AppointmentStatus.NO_SHOW,
        ):
            raise ValidationException(
                message=f"Cannot reschedule an appointment with status '{apt.status.value}'."
            )

        if apt.slot_id == new_slot_id:
            return self._to_response(apt)

        new_slot = await self.repo.get_slot_for_update(db, new_slot_id, lab_id=apt.lab_id)
        if not new_slot:
            raise NotFoundException(message=f"Appointment slot with id '{new_slot_id}' was not found.")
        if not new_slot.is_active:
            raise ValidationException(message="This appointment slot is currently closed.")
        if new_slot.booked_count >= new_slot.capacity:
            raise ValidationException(message="This appointment slot is fully booked.")

        if apt.slot_id:
            old_slot = await self.repo.get_slot_for_update(db, apt.slot_id, lab_id=apt.lab_id)
            if old_slot and old_slot.booked_count > 0:
                old_slot.booked_count -= 1

        new_slot.booked_count += 1
        apt.slot_id = new_slot_id
        if apt.status == AppointmentStatus.PENDING:
            apt.status = AppointmentStatus.CONFIRMED

        await db.flush()
        loaded = await self.repo.get_by_id(db, apt.id, lab_id=apt.lab_id)
        return self._to_response(loaded or apt)

    async def confirm_appointment(
        self, db: AsyncSession, appointment_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentResponse:
        """Confirm a pending appointment."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        if apt.status != AppointmentStatus.PENDING:
            raise ValidationException(
                message=f"Cannot confirm an appointment with status '{apt.status.value}'."
            )

        updated = await self.repo.update_status(db, apt, AppointmentStatus.CONFIRMED)

        # Trigger outbound confirmation notification
        try:
            from app.services.notification_service import NotificationService

            await NotificationService().send_appointment_confirmed_notification(db, updated)
        except Exception as exc:
            import logging

            logging.getLogger(__name__).warning(
                "Failed to auto-send confirmation notification for appointment %s: %s", updated.id, exc
            )

        return self._to_response(updated)

    async def complete_appointment(
        self, db: AsyncSession, appointment_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentResponse:
        """Mark appointment as completed."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        if apt.status not in (AppointmentStatus.CONFIRMED, AppointmentStatus.CHECKED_IN):
            raise ValidationException(
                message=f"Cannot complete an appointment with status '{apt.status.value}'."
            )

        updated = await self.repo.update_status(db, apt, AppointmentStatus.COMPLETED)
        return self._to_response(updated)

    async def cancel_appointment(
        self, db: AsyncSession, appointment_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentResponse:
        """Cancel appointment and release slot capacity."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        if apt.status in (AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED):
            raise ValidationException(
                message=f"Cannot cancel an appointment with status '{apt.status.value}'."
            )

        if apt.slot_id:
            slot = await self.repo.get_slot_for_update(db, apt.slot_id, lab_id=apt.lab_id)
            if slot and slot.booked_count > 0:
                slot.booked_count -= 1

        updated = await self.repo.update_status(db, apt, AppointmentStatus.CANCELLED)
        return self._to_response(updated)

    async def check_in_appointment(
        self, db: AsyncSession, appointment_id: UUID, lab_id: UUID | None = None
    ) -> AppointmentResponse:
        """Mark patient arrival — moves pending/confirmed appointments to checked_in."""
        apt = await self.repo.get_by_id(db, appointment_id, lab_id=lab_id)
        if not apt:
            raise NotFoundException(message=f"Appointment with id '{appointment_id}' was not found.")
        if apt.status in (
            AppointmentStatus.COMPLETED,
            AppointmentStatus.CANCELLED,
            AppointmentStatus.NO_SHOW,
        ):
            raise ValidationException(
                message=f"Cannot check in an appointment with status '{apt.status.value}'."
            )
        if apt.status == AppointmentStatus.CHECKED_IN:
            return self._to_response(apt)

        updated = await self.repo.update_status(db, apt, AppointmentStatus.CHECKED_IN)
        return self._to_response(updated)

    async def quick_book_appointment(
        self, db: AsyncSession, data: QuickAppointmentCreate
    ) -> AppointmentResponse:
        """
        Quickly book an appointment for an existing or unregistered customer,
        allocate/ensure slot capacity, and dispatch confirmation email.
        """
        from app.models.patient import Gender, Patient
        from app.services.notification_service import NotificationService

        if data.lab_id is None:
            data.lab_id = await LabRepository().get_default_lab_id(db)

        # 1. Resolve or register patient
        patient: Patient | None = None
        if data.patient_id:
            patient = await self.patient_repo.get_by_id(db, data.patient_id, lab_id=data.lab_id)
            if not patient:
                raise NotFoundException(message=f"Patient with id '{data.patient_id}' was not found.")
            if data.email:
                patient.email = data.email.strip()
                await db.flush()
        elif data.phone:
            patient = await self.patient_repo.get_by_lab_and_phone(db, data.lab_id, data.phone.strip())
            if patient:
                if data.email:
                    patient.email = data.email.strip()
                    await db.flush()
            else:
                if not data.name or not data.name.strip():
                    raise ValidationException(message="Patient name is required for registration.")

                patient_code = await self.patient_repo.get_next_patient_code(db, data.lab_id)
                gender_enum = Gender.MALE
                if data.gender:
                    g_val = data.gender.lower()
                    if g_val == "female":
                        gender_enum = Gender.FEMALE
                    elif g_val == "other":
                        gender_enum = Gender.OTHER

                patient = Patient(
                    lab_id=data.lab_id,
                    patient_code=patient_code,
                    name=data.name.strip(),
                    phone=data.phone.strip(),
                    email=data.email.strip() if data.email else None,
                    gender=gender_enum,
                    age=data.age,
                    address=data.address.strip() if data.address else None,
                    country="Nepal",
                )
                db.add(patient)
                await db.flush()
        else:
            raise ValidationException(message="Either patient_id or patient phone and name must be provided.")

        # 2. Check for existing active appointment
        active = await self.repo.find_active_appointment_for_patient(db, patient.id, data.lab_id)
        if active:
            raise ValidationException(
                message=(
                    "This patient already has an active appointment."
                    " Reschedule or cancel it before booking again."
                )
            )

        # 3. Resolve Slot
        slot_id = data.slot_id
        if not slot_id and data.target_date:
            day_slots = await self.ensure_day_slots(db, data.target_date, lab_id=data.lab_id)
            if data.time_window:
                for s in day_slots:
                    time_str = s.start_at.strftime("%H:%M")
                    if time_str in data.time_window or data.time_window in f"{time_str}":
                        slot_id = s.id
                        break
            if not slot_id and day_slots:
                avail = [s for s in day_slots if not s.is_full and s.is_active]
                if avail:
                    slot_id = avail[0].id

        if slot_id:
            slot = await self.repo.get_slot_for_update(db, slot_id, lab_id=data.lab_id)
            if not slot:
                raise NotFoundException(message=f"Appointment slot with id '{slot_id}' was not found.")
            if not slot.is_active:
                raise ValidationException(message="This appointment slot is currently closed.")
            if slot.booked_count >= slot.capacity:
                raise ValidationException(message="This appointment slot is fully booked.")
            slot.booked_count += 1

        # 4. Create appointment
        create_payload = AppointmentCreate(
            lab_id=data.lab_id,
            patient_id=patient.id,
            slot_id=slot_id,
            booked_via=data.booked_via,
            contact_name=data.name or patient.name,
            contact_phone=data.phone or patient.phone,
            notes=data.notes,
        )
        apt = await self.repo.create(db, create_payload, initial_status=AppointmentStatus.CONFIRMED)
        await db.flush()

        # Reload with relations for notification
        loaded = await self.repo.get_by_id(db, apt.id, lab_id=data.lab_id)
        apt_to_notify = loaded or apt

        # 5. Dispatch confirmation email (non-blocking with timeout)
        try:
            import asyncio

            await asyncio.wait_for(
                NotificationService().send_appointment_confirmed_notification(db, apt_to_notify),
                timeout=10.0,
            )
        except Exception as exc:
            import logging

            logging.getLogger(__name__).warning(
                "Failed or timed out sending confirmation notification: %s", exc
            )

        await db.commit()
        return self._to_response(apt_to_notify)
