<?php

namespace App\Domain\Booking;

use App\Models\Appointment;
use App\Models\Dentist;
use App\Models\ScheduleException;
use App\Support\Tenancy;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;

/**
 * Availability = weekly schedule windows
 *              − whole/partial day-off exceptions
 *              + 'extra' exception windows
 *              − occupied appointments
 *              − slots already in the past (clinic timezone).
 *
 * All public input/output is in the CLINIC timezone; storage stays UTC.
 */
class SlotEngine
{
    public function __construct(protected Tenancy $tenancy)
    {
    }

    /** @return Collection<int, array{starts_at_utc: CarbonImmutable, label: string}> */
    public function availableSlots(Dentist $dentist, CarbonImmutable $date): Collection
    {
        $tz = $this->tenancy->get()?->timezone ?? config('app.timezone');
        $date = $date->setTimezone($tz)->startOfDay();

        $windows = $this->windowsFor($dentist, $date, $tz);
        if ($windows->isEmpty()) {
            return collect();
        }

        $occupied = $this->occupiedRanges($dentist, $date, $tz);
        $now = CarbonImmutable::now($tz);
        $step = $dentist->stepMinutes();
        $duration = $dentist->slot_duration_min;

        $slots = collect();
        foreach ($windows as [$winStart, $winEnd]) {
            for ($cursor = $winStart;
                 $cursor->addMinutes($duration)->lte($winEnd);
                 $cursor = $cursor->addMinutes($step)) {
                $slotEnd = $cursor->addMinutes($duration);

                if ($cursor->lte($now)) {
                    continue;
                }
                if ($this->overlapsAny($cursor, $slotEnd, $occupied)) {
                    continue;
                }

                $slots->push([
                    'starts_at_utc' => $cursor->utc(),
                    'label' => $cursor->format('H:i'),
                ]);
            }
        }

        return $slots->unique(fn ($s) => $s['starts_at_utc']->timestamp)->values();
    }

    public function isAvailable(Dentist $dentist, CarbonImmutable $startUtc): bool
    {
        $tz = $this->tenancy->get()?->timezone ?? config('app.timezone');
        $local = $startUtc->setTimezone($tz);

        return $this->availableSlots($dentist, $local)
            ->contains(fn ($s) => $s['starts_at_utc']->equalTo($startUtc));
    }

    /** @return Collection<int, array{0: CarbonImmutable, 1: CarbonImmutable}> local-time windows */
    protected function windowsFor(Dentist $dentist, CarbonImmutable $date, string $tz): Collection
    {
        $exceptions = ScheduleException::query()
            ->whereDate('date', $date->toDateString())
            ->where(fn ($q) => $q->whereNull('dentist_id')->orWhere('dentist_id', $dentist->id))
            ->get();

        if ($exceptions->contains(fn ($e) => $e->type === 'off' && $e->start_time === null)) {
            return collect(); // whole day off (dentist or clinic-wide)
        }

        $windows = $dentist->schedules
            ->where('weekday', $date->dayOfWeek)
            ->map(fn ($s) => [
                $date->setTimeFromTimeString($s->start_time)->shiftTimezone($tz),
                $date->setTimeFromTimeString($s->end_time)->shiftTimezone($tz),
            ]);

        // Add ad-hoc 'extra' windows for this date.
        foreach ($exceptions->where('type', 'extra') as $extra) {
            if ($extra->start_time && $extra->end_time) {
                $windows->push([
                    $date->setTimeFromTimeString($extra->start_time)->shiftTimezone($tz),
                    $date->setTimeFromTimeString($extra->end_time)->shiftTimezone($tz),
                ]);
            }
        }

        // Subtract partial 'off' windows.
        $offs = $exceptions->where('type', 'off')->whereNotNull('start_time')
            ->map(fn ($e) => [
                $date->setTimeFromTimeString($e->start_time)->shiftTimezone($tz),
                $date->setTimeFromTimeString($e->end_time)->shiftTimezone($tz),
            ]);

        return $windows
            ->flatMap(fn ($w) => $this->subtractRanges($w, $offs))
            ->filter(fn ($w) => $w[0]->lt($w[1]))
            ->values();
    }

    protected function subtractRanges(array $window, Collection $offs): array
    {
        $pieces = [$window];
        foreach ($offs as [$offStart, $offEnd]) {
            $next = [];
            foreach ($pieces as [$start, $end]) {
                if ($offEnd->lte($start) || $offStart->gte($end)) {
                    $next[] = [$start, $end];
                    continue;
                }
                if ($offStart->gt($start)) {
                    $next[] = [$start, $offStart];
                }
                if ($offEnd->lt($end)) {
                    $next[] = [$offEnd, $end];
                }
            }
            $pieces = $next;
        }

        return $pieces;
    }

    protected function occupiedRanges(Dentist $dentist, CarbonImmutable $date, string $tz): Collection
    {
        return Appointment::query()
            ->where('dentist_id', $dentist->id)
            ->holdingSlot()
            ->between($date->utc(), $date->addDay()->utc())
            ->get(['starts_at', 'ends_at'])
            ->map(fn ($a) => [
                CarbonImmutable::parse($a->starts_at)->setTimezone($tz),
                CarbonImmutable::parse($a->ends_at)->setTimezone($tz),
            ]);
    }

    protected function overlapsAny(CarbonImmutable $start, CarbonImmutable $end, Collection $ranges): bool
    {
        return $ranges->contains(fn ($r) => $start->lt($r[1]) && $end->gt($r[0]));
    }
}
