<?php

declare(strict_types=1);

namespace App\Services\Booking;

use Carbon\CarbonImmutable;

/**
 * All slot arithmetic lives here, and nowhere else.
 *
 * Every calculation that touches a calendar day is done in the studio's local
 * timezone and only then converted to UTC. That is what makes the clocks-change
 * days come out right: on the last Sunday in March a London day is 23 hours
 * long, and in October it is 25, so a day's grid cannot be assumed to hold a
 * fixed number of slots.
 */
final readonly class SlotCalculator
{
    public function __construct(
        private int $slotMinutes,
        private string $timezone,
    ) {}

    public static function fromConfig(): self
    {
        return new self(
            (int) config('studio.slot_minutes'),
            (string) config('studio.timezone'),
        );
    }

    public function slotMinutes(): int
    {
        return $this->slotMinutes;
    }

    public function timezone(): string
    {
        return $this->timezone;
    }

    /** How many slots a given number of hours occupies. */
    public function slotsForHours(float $hours): int
    {
        return (int) round($hours * 60 / $this->slotMinutes);
    }

    public function hoursForSlots(int $slots): float
    {
        return $slots * $this->slotMinutes / 60;
    }

    /**
     * Round a moment down to the slot boundary it sits in.
     *
     * Rounding happens against the local wall clock, not against UTC, so that
     * boundaries land on :00 and :30 locally even in timezones offset by a
     * fraction of an hour.
     */
    public function floorToSlot(CarbonImmutable $moment): CarbonImmutable
    {
        $local = $moment->setTimezone($this->timezone);

        $minutes = (int) floor($local->minute / $this->slotMinutes) * $this->slotMinutes;

        return $local
            ->setTime($local->hour, $minutes, 0)
            ->setTimezone('UTC');
    }

    /**
     * The UTC window covering one local calendar day.
     *
     * @return array{0: CarbonImmutable, 1: CarbonImmutable} start inclusive, end exclusive
     */
    public function dayWindow(CarbonImmutable $date): array
    {
        $start = $date->setTimezone($this->timezone)->startOfDay();

        return [
            $start->setTimezone('UTC'),
            $start->addDay()->setTimezone('UTC'),
        ];
    }

    /**
     * Every slot start between two moments.
     *
     * Stepping in real minutes rather than by local wall clock means a day
     * that gains or loses an hour yields the right number of slots without
     * any special casing.
     *
     * @return array<int, CarbonImmutable>
     */
    public function slotsBetween(CarbonImmutable $from, CarbonImmutable $to): array
    {
        $slots = [];
        $cursor = $from->setTimezone('UTC');
        $end = $to->setTimezone('UTC');

        while ($cursor->lessThan($end)) {
            $slots[] = $cursor;
            $cursor = $cursor->addMinutes($this->slotMinutes);
        }

        return $slots;
    }

    /**
     * The slots a booking of a given length occupies, starting at $start.
     *
     * @return array<int, CarbonImmutable>
     */
    public function slotsForBooking(CarbonImmutable $start, int $slotCount): array
    {
        $slots = [];
        $cursor = $start->setTimezone('UTC');

        for ($i = 0; $i < $slotCount; $i++) {
            $slots[] = $cursor;
            $cursor = $cursor->addMinutes($this->slotMinutes);
        }

        return $slots;
    }

    public function endOfBooking(CarbonImmutable $start, int $slotCount): CarbonImmutable
    {
        return $start->setTimezone('UTC')->addMinutes($slotCount * $this->slotMinutes);
    }

    /** Whether a moment sits exactly on a slot boundary. */
    public function isOnBoundary(CarbonImmutable $moment): bool
    {
        return $this->floorToSlot($moment)->equalTo($moment->setTimezone('UTC'));
    }

    /** Local wall-clock time of day, for comparing against opening hours. */
    public function localTimeString(CarbonImmutable $moment): string
    {
        return $moment->setTimezone($this->timezone)->format('H:i:s');
    }
}
