<?php

namespace App\Services;

use App\Enums\AppointmentStatus;
use App\Enums\ScheduleExceptionType;
use App\Exceptions\DomainException;
use App\Models\Appointment;
use App\Models\Doctor;
use App\Models\DoctorLocationAssignment;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;

class ScheduleService
{
    public function getAssignment(int $organizationId, int $doctorId, int $locationId): DoctorLocationAssignment
    {
        $assignment = DoctorLocationAssignment::query()
            ->with(['doctor.user', 'location'])
            ->where('organization_id', $organizationId)
            ->where('doctor_id', $doctorId)
            ->where('location_id', $locationId)
            ->where('is_active', true)
            ->whereHas('doctor', fn ($q) => $q->where('is_active', true))
            ->whereHas('location', fn ($q) => $q->where('is_active', true))
            ->first();

        if (! $assignment) {
            throw new DomainException('DOCTOR_NOT_ASSIGNED', 'Doctor is not active at the selected location.', 422);
        }

        return $assignment;
    }

    public function availableSlots(
        Doctor $doctor,
        int $locationId,
        string $date,
        ?int $ignoreAppointmentId = null
    ): array {
        $assignment = $this->getAssignment($doctor->organization_id, $doctor->id, $locationId);
        $timezone = $doctor->organization?->timezone ?? config('clinic.default_timezone');
        $day = CarbonImmutable::parse($date, $timezone)->startOfDay();

        if ($day->lt(CarbonImmutable::now($timezone)->startOfDay())) {
            return [];
        }

        if ($day->gt(CarbonImmutable::now($timezone)->startOfDay()->addDays($assignment->booking_window_days))) {
            return [];
        }

        $sessions = $this->sessionsForDay($assignment, $day);

        if ($sessions === []) {
            return [];
        }

        $slots = [];
        foreach ($sessions as [$sessionStart, $sessionEnd]) {
            for ($cursor = $sessionStart; $cursor->addMinutes($assignment->slot_minutes)->lte($sessionEnd); $cursor = $cursor->addMinutes($assignment->slot_minutes)) {
                $end = $cursor->addMinutes($assignment->slot_minutes);

                if ($cursor->isPast() && $day->isToday()) {
                    continue;
                }

                if ($this->isBlockedByException($assignment, $cursor, $end)) {
                    continue;
                }

                if ($this->hasAppointmentConflict($assignment, $cursor->utc(), $end->utc(), $ignoreAppointmentId)) {
                    continue;
                }

                $slots[] = [
                    'start_at' => $cursor->toIso8601String(),
                    'end_at' => $end->toIso8601String(),
                    'timezone' => $timezone,
                    'slot_minutes' => $assignment->slot_minutes,
                ];
            }
        }

        return $slots;
    }

    public function capacitySlotsForDate(DoctorLocationAssignment $assignment, string $date): int
    {
        $assignment->loadMissing([
            'doctor.organization',
            'location.organization',
            'workingHours',
            'scheduleExceptions',
        ]);

        $timezone = $assignment->doctor->organization?->timezone
            ?? $assignment->location->organization?->timezone
            ?? config('clinic.default_timezone');
        $day = CarbonImmutable::parse($date, $timezone)->startOfDay();
        $dayStartUtc = $day->utc();
        $dayEndUtc = $day->endOfDay()->utc();

        $exceptions = $assignment->scheduleExceptions
            ->filter(fn ($e) => $e->starts_at->lt($dayEndUtc) && $e->ends_at->gt($dayStartUtc));

        $fullDayClosure = $exceptions->contains(function ($exception) use ($timezone, $day) {
            return in_array($exception->type, [ScheduleExceptionType::LEAVE, ScheduleExceptionType::HOLIDAY], true)
                && $exception->starts_at->setTimezone($timezone)->lte($day->startOfDay())
                && $exception->ends_at->setTimezone($timezone)->gte($day->endOfDay());
        });

        if ($fullDayClosure) {
            return 0;
        }

        $special = $exceptions->where('type', ScheduleExceptionType::SPECIAL_HOURS);
        $sessions = $special->isNotEmpty()
            ? $special->map(fn ($e) => [
                CarbonImmutable::instance($e->starts_at)->setTimezone($timezone),
                CarbonImmutable::instance($e->ends_at)->setTimezone($timezone),
            ])->values()->all()
            : $assignment->workingHours
                ->where('weekday', $day->isoWeekday())
                ->where('is_active', true)
                ->sortBy('start_time')
                ->map(fn ($hours) => [
                    CarbonImmutable::parse($day->toDateString().' '.$hours->start_time, $timezone),
                    CarbonImmutable::parse($day->toDateString().' '.$hours->end_time, $timezone),
                ])->values()->all();

        $blocking = $exceptions->filter(fn ($e) => in_array($e->type, [
            ScheduleExceptionType::LEAVE,
            ScheduleExceptionType::HOLIDAY,
            ScheduleExceptionType::BLOCKED_TIME,
        ], true));

        $count = 0;
        foreach ($sessions as [$sessionStart, $sessionEnd]) {
            for ($cursor = $sessionStart; $cursor->addMinutes($assignment->slot_minutes)->lte($sessionEnd); $cursor = $cursor->addMinutes($assignment->slot_minutes)) {
                $end = $cursor->addMinutes($assignment->slot_minutes);
                $slotStartUtc = $cursor->utc();
                $slotEndUtc = $end->utc();
                $blocked = $blocking->contains(fn ($e) => $e->starts_at->lt($slotEndUtc) && $e->ends_at->gt($slotStartUtc));

                if (! $blocked) {
                    $count++;
                }
            }
        }

        return $count;
    }

    public function assertSlotAvailable(
        DoctorLocationAssignment $assignment,
        CarbonInterface $startUtc,
        ?int $ignoreAppointmentId = null,
        bool $allowPast = false
    ): CarbonImmutable {
        $timezone = $assignment->doctor->organization?->timezone
            ?? $assignment->location->organization?->timezone
            ?? config('clinic.default_timezone');

        $startLocal = CarbonImmutable::instance($startUtc)->setTimezone($timezone);
        $endLocal = $startLocal->addMinutes($assignment->slot_minutes);

        $today = CarbonImmutable::now($timezone)->startOfDay();
        if (! $allowPast && $startLocal->lt(CarbonImmutable::now($timezone))) {
            throw new DomainException('SLOT_NOT_AVAILABLE', 'Appointment time cannot be in the past.', 409);
        }

        if (! $allowPast && $startLocal->startOfDay()->gt($today->addDays($assignment->booking_window_days))) {
            throw new DomainException('SLOT_NOT_AVAILABLE', 'Appointment is outside the booking window.', 409);
        }

        $sessions = $this->sessionsForDay($assignment, $startLocal->startOfDay());
        $insideSession = collect($sessions)->contains(
            fn (array $session) => $startLocal->gte($session[0]) && $endLocal->lte($session[1])
        );

        if (! $insideSession) {
            throw new DomainException('OUTSIDE_WORKING_HOURS', 'Selected time is outside the doctor working hours.', 409);
        }

        if ($this->isBlockedByException($assignment, $startLocal, $endLocal)) {
            throw new DomainException('SLOT_NOT_AVAILABLE', 'Selected time is blocked by a schedule exception.', 409);
        }

        $endUtc = $endLocal->utc();
        if ($this->hasAppointmentConflict($assignment, CarbonImmutable::instance($startUtc)->utc(), $endUtc, $ignoreAppointmentId, true)) {
            throw new DomainException('SLOT_NOT_AVAILABLE', 'The appointment slot is no longer available.', 409);
        }

        return $endUtc;
    }

    private function sessionsForDay(DoctorLocationAssignment $assignment, CarbonImmutable $day): array
    {
        $timezone = $assignment->doctor->organization?->timezone
            ?? $assignment->location->organization?->timezone
            ?? config('clinic.default_timezone');

        $exceptions = $assignment->scheduleExceptions()
            ->where('starts_at', '<', $day->endOfDay()->utc())
            ->where('ends_at', '>', $day->startOfDay()->utc())
            ->get();

        if ($exceptions->contains(fn ($e) => in_array($e->type, [ScheduleExceptionType::LEAVE, ScheduleExceptionType::HOLIDAY], true)
            && $e->starts_at->setTimezone($timezone)->lte($day->startOfDay())
            && $e->ends_at->setTimezone($timezone)->gte($day->endOfDay()))) {
            return [];
        }

        $special = $exceptions->where('type', ScheduleExceptionType::SPECIAL_HOURS);
        if ($special->isNotEmpty()) {
            return $special->map(fn ($e) => [
                CarbonImmutable::instance($e->starts_at)->setTimezone($timezone),
                CarbonImmutable::instance($e->ends_at)->setTimezone($timezone),
            ])->values()->all();
        }

        return $assignment->workingHours()
            ->where('weekday', $day->isoWeekday())
            ->where('is_active', true)
            ->orderBy('start_time')
            ->get()
            ->map(function ($hours) use ($day, $timezone) {
                return [
                    CarbonImmutable::parse($day->toDateString().' '.$hours->start_time, $timezone),
                    CarbonImmutable::parse($day->toDateString().' '.$hours->end_time, $timezone),
                ];
            })->all();
    }

    private function isBlockedByException(
        DoctorLocationAssignment $assignment,
        CarbonInterface $startLocal,
        CarbonInterface $endLocal
    ): bool {
        $startUtc = CarbonImmutable::instance($startLocal)->utc();
        $endUtc = CarbonImmutable::instance($endLocal)->utc();

        return $assignment->scheduleExceptions()
            ->whereIn('type', [
                ScheduleExceptionType::LEAVE->value,
                ScheduleExceptionType::HOLIDAY->value,
                ScheduleExceptionType::BLOCKED_TIME->value,
            ])
            ->where('starts_at', '<', $endUtc)
            ->where('ends_at', '>', $startUtc)
            ->exists();
    }

    private function hasAppointmentConflict(
        DoctorLocationAssignment $assignment,
        CarbonInterface $startUtc,
        CarbonInterface $endUtc,
        ?int $ignoreAppointmentId = null,
        bool $lock = false
    ): bool {
        $query = Appointment::query()
            ->where('doctor_id', $assignment->doctor_id)
            ->where('location_id', $assignment->location_id)
            ->whereNotIn('status', [
                AppointmentStatus::CANCELLED->value,
                AppointmentStatus::NO_SHOW->value,
            ])
            ->where('scheduled_start_at', '<', $endUtc)
            ->where('scheduled_end_at', '>', $startUtc);

        if ($ignoreAppointmentId) {
            $query->whereKeyNot($ignoreAppointmentId);
        }

        if ($lock) {
            $query->lockForUpdate();
        }

        return $query->exists();
    }
}
