<?php

declare(strict_types=1);

namespace App\Livewire;

use App\Enums\BookingType;
use App\Enums\SessionType;
use App\Exceptions\SlotUnavailableException;
use App\Models\Engineer;
use App\Models\Experience;
use App\Models\Room;
use App\Models\RoomAddon;
use App\Services\Booking\AvailabilityService;
use App\Services\Booking\BookingService;
use App\Services\Booking\PricingService;
use App\Services\Booking\SlotCalculator;
use App\Support\Slot;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;

/**
 * The customer-facing booking flow.
 *
 * This component decides nothing about availability or price. Every slot it
 * renders and every figure it shows comes from AvailabilityService and
 * PricingService, and the booking itself goes through BookingService, which
 * re-checks the lot server-side. A tampered payload can therefore produce a
 * rejection, but never a booking on terms the studio did not offer.
 *
 * It also does no date arithmetic. Slot times arrive as UTC instants with a
 * pre-formatted local label attached, which is what keeps the clocks-change
 * days correct without this class knowing they exist.
 */
class BookingFlow extends Component
{
    public int $step = 1;

    public ?int $roomId = null;

    public ?int $experienceId = null;

    public string $sessionType = SessionType::WithEngineer->value;

    /** @var array<int, int> room addon id => quantity */
    public array $addons = [];

    public ?string $date = null;

    public ?string $startsAt = null;

    public int $durationSlots = 4;

    public ?int $preferredEngineerId = null;

    public ?int $guestCount = null;

    public bool $usePackage = false;

    public ?string $error = null;

    public function mount(): void
    {
        $this->roomId = Room::query()->active()->orderBy('sort_order')->value('id');
        $this->syncDurationToRoom();
        $this->date = $this->firstBookableDate();
    }

    // ------------------------------------------------------------- selections

    public function selectRoom(int $roomId): void
    {
        $this->roomId = $roomId;
        $this->experienceId = null;
        $this->startsAt = null;
        $this->addons = [];
        $this->guestCount = null;
        $this->error = null;

        // Studio C has no engineer to offer, so a with-engineer selection
        // carried over from Studio A would be meaningless.
        if (! $this->room?->takes_engineer) {
            $this->sessionType = SessionType::WithoutEngineer->value;
        }

        $this->syncDurationToRoom();

        // A date that had space in Studio A may be full in Studio B, so the
        // default follows the room rather than stranding the customer on an
        // empty calendar.
        $this->date = $this->firstBookableDate();
    }

    public function selectExperience(int $experienceId): void
    {
        $experience = Experience::active()->findOrFail($experienceId);

        $this->experienceId = $experience->id;
        $this->roomId = $experience->room_id;
        $this->durationSlots = $experience->slotCount();
        $this->guestCount = $experience->included_guests;
        $this->startsAt = null;
        $this->error = null;
    }

    public function selectSessionType(string $type): void
    {
        $this->sessionType = $type;
        $this->startsAt = null;
        $this->error = null;
    }

    public function selectSlot(string $isoStart): void
    {
        $this->startsAt = $isoStart;
        $this->error = null;

        // A three hour session picked at 21:00 would otherwise silently keep
        // a duration that runs past what is actually free.
        $this->durationSlots = min($this->durationSlots, max($this->minimumSlots(), $this->maxDurationSlots()));
    }

    public function changeDuration(int $delta): void
    {
        $this->durationSlots = max(
            $this->minimumSlots(),
            min($this->durationSlots + $delta, $this->maxDurationSlots()),
        );
    }

    public function toggleAddon(int $addonId): void
    {
        if (isset($this->addons[$addonId])) {
            unset($this->addons[$addonId]);
        } else {
            $this->addons[$addonId] = 1;
        }
    }

    public function updatedDate(): void
    {
        $this->startsAt = null;
        $this->error = null;
    }

    public function goToStep(int $step): void
    {
        $this->error = null;
        $this->step = $step;
    }

    // -------------------------------------------------------------- computed

    #[Computed]
    public function rooms(): Collection
    {
        return Room::query()->active()->orderBy('sort_order')->get();
    }

    #[Computed]
    public function room(): ?Room
    {
        return $this->roomId ? $this->rooms->firstWhere('id', $this->roomId) : null;
    }

    #[Computed]
    public function experience(): ?Experience
    {
        return $this->experienceId ? Experience::find($this->experienceId) : null;
    }

    #[Computed]
    public function experiences(): Collection
    {
        return Experience::active()->with('room')->orderBy('sort_order')->get();
    }

    #[Computed]
    public function engineers(): Collection
    {
        return Engineer::query()->active()->orderBy('sort_order')->get();
    }

    #[Computed]
    public function roomAddons(): Collection
    {
        return $this->room?->addons()->active()->get() ?? collect();
    }

    /** The package this booking would draw on, if the customer wants it. */
    #[Computed]
    public function availablePackage()
    {
        if (! $this->room?->package_redeemable || $this->experienceId !== null) {
            return null;
        }

        return auth()->user()->usablePackage();
    }

    #[Computed]
    public function activePackage()
    {
        return $this->usePackage ? $this->availablePackage : null;
    }

    /**
     * @return array<int, Slot>
     */
    #[Computed]
    public function grid(): array
    {
        if ($this->room === null || $this->date === null) {
            return [];
        }

        $availability = app(AvailabilityService::class);
        $date = CarbonImmutable::parse($this->date, config('studio.timezone'));

        // Studio D is sold as whole days, so it gets start-of-day options
        // rather than a half-hourly grid.
        if ($this->room->isDayRate() && $this->experienceId === null) {
            return $availability->dayStartOptions($this->room, $date);
        }

        return $availability->grid(
            $this->room,
            $date,
            SessionType::tryFrom($this->sessionType),
            $this->activePackage,
        );
    }

    /** The chosen slot, if it is still on the grid and still available. */
    #[Computed]
    public function selectedSlot(): ?Slot
    {
        if ($this->startsAt === null) {
            return null;
        }

        foreach ($this->grid as $slot) {
            if ($slot->startsAt->toIso8601String() === $this->startsAt) {
                return $slot;
            }
        }

        return null;
    }

    #[Computed]
    public function quote()
    {
        if ($this->room === null) {
            return null;
        }

        return app(PricingService::class)->quote(
            $this->room,
            $this->durationSlots,
            SessionType::tryFrom($this->sessionType),
            $this->selectedAddons(),
            $this->activePackage,
            $this->experience,
            $this->guestCount,
        );
    }

    #[Computed]
    public function endsAtLabel(): ?string
    {
        if ($this->startsAt === null) {
            return null;
        }

        return app(SlotCalculator::class)
            ->endOfBooking(CarbonImmutable::parse($this->startsAt), $this->durationSlots)
            ->setTimezone(config('studio.timezone'))
            ->format('H:i');
    }

    #[Computed]
    public function hours(): float
    {
        return app(SlotCalculator::class)->hoursForSlots($this->durationSlots);
    }

    // ---------------------------------------------------------------- confirm

    public function confirm(BookingService $bookings)
    {
        $this->error = null;

        if ($this->room === null || $this->startsAt === null) {
            $this->error = 'Pick a room and a time first.';

            return null;
        }

        try {
            $booking = $bookings->create(
                user: auth()->user(),
                room: $this->room,
                startsAt: CarbonImmutable::parse($this->startsAt),
                slotCount: $this->durationSlots,
                sessionType: SessionType::tryFrom($this->sessionType),
                type: $this->bookingType(),
                addons: $this->selectedAddons(),
                package: $this->activePackage,
                preferredEngineerId: $this->preferredEngineerId,
                guestCount: $this->guestCount,
                experience: $this->experience,
            );
        } catch (SlotUnavailableException $e) {
            // The commonest cause is a genuine race — somebody confirmed the
            // same time seconds ago. Refresh the grid so the customer sees
            // the truth rather than being told no against a stale screen.
            $this->error = $e->getMessage();
            $this->startsAt = null;
            unset($this->grid);

            return null;
        }

        return $this->redirectRoute('checkout', ['booking' => $booking->reference], navigate: true);
    }

    // --------------------------------------------------------------- helpers

    /** @return array<int, array{addon: RoomAddon, quantity: int}> */
    private function selectedAddons(): array
    {
        return $this->roomAddons
            ->whereIn('id', array_keys($this->addons))
            ->map(fn (RoomAddon $addon) => [
                'addon' => $addon,
                'quantity' => $this->addons[$addon->id] ?? 1,
            ])
            ->values()
            ->all();
    }

    private function bookingType(): BookingType
    {
        return match (true) {
            $this->experienceId !== null => BookingType::Birthday,
            (bool) $this->room?->isDayRate() => BookingType::DayHire,
            default => BookingType::Session,
        };
    }

    private function minimumSlots(): int
    {
        if ($this->experience !== null) {
            return $this->experience->slotCount();
        }

        return $this->room?->isDayRate()
            ? ($this->room->slotsPerDay())
            : ($this->room?->minimumSlots() ?? 4);
    }

    /**
     * How far the session can run before it hits something.
     *
     * Walks forward from the chosen start through consecutive available
     * slots, so the stepper can never offer a length that overlaps the next
     * booking or runs past closing.
     */
    private function maxDurationSlots(): int
    {
        if ($this->startsAt === null || $this->room?->isDayRate() || $this->experience !== null) {
            return $this->minimumSlots();
        }

        $run = 0;
        $counting = false;

        foreach ($this->grid as $slot) {
            if ($slot->startsAt->toIso8601String() === $this->startsAt) {
                $counting = true;
            }

            if (! $counting) {
                continue;
            }

            if (! $slot->isAvailable()) {
                break;
            }

            $run++;
        }

        // A whole working day is plenty; beyond that the studio would rather
        // talk to the customer than take a twelve hour hourly booking.
        return max($this->minimumSlots(), min($run, 24));
    }

    private function syncDurationToRoom(): void
    {
        $this->durationSlots = $this->minimumSlots();
    }

    /**
     * The first date that actually has something bookable on it.
     *
     * Not the same as the earliest legal date, and the difference matters.
     * Someone opening the page at 23:00 has a lead-time cutoff of 23:00
     * tomorrow — which is past the last slot of the day, so the "earliest"
     * date is an entirely empty calendar. Landing on that and being told
     * "nothing free, try another day" is a bad first impression and leaves
     * the customer guessing which day to try.
     *
     * Scans forward a fortnight, which also skips over days that are simply
     * fully booked. Two cheap queries per day, and it almost always stops on
     * the first or second.
     */
    private function firstBookableDate(): string
    {
        $earliest = CarbonImmutable::parse($this->earliestSelectableDate(), config('studio.timezone'));

        if ($this->room === null) {
            return $earliest->format('Y-m-d');
        }

        $availability = app(AvailabilityService::class);
        $isDayPicker = $this->room->isDayRate() && $this->experienceId === null;

        // Day-rate options are whole blocks already, so one free entry is a
        // bookable day. Hourly rooms need an unbroken run.
        $needed = $isDayPicker ? 1 : $this->minimumSlots();

        for ($offset = 0; $offset < 14; $offset++) {
            $day = $earliest->addDays($offset);

            $slots = $isDayPicker
                ? $availability->dayStartOptions($this->room, $day)
                : $availability->grid(
                    $this->room,
                    $day,
                    SessionType::tryFrom($this->sessionType),
                    $this->activePackage,
                );

            if ($this->hasRunOf($slots, $needed)) {
                return $day->format('Y-m-d');
            }
        }

        return $earliest->format('Y-m-d');
    }

    /**
     * Whether the day contains a long enough unbroken run of free slots.
     *
     * A day with three scattered half hours free is not a day you can book a
     * two hour session on, and offering it as the default would be a lie.
     *
     * @param  array<int, Slot>  $slots
     */
    private function hasRunOf(array $slots, int $needed): bool
    {
        $run = 0;

        foreach ($slots as $slot) {
            $run = $slot->isAvailable() ? $run + 1 : 0;

            if ($run >= $needed) {
                return true;
            }
        }

        return false;
    }

    /** The first date a booking could legally start, in studio-local terms. */
    private function earliestSelectableDate(): string
    {
        $leadHours = $this->room?->lead_time_hours ?? 24;

        if (auth()->user()?->usablePackage()?->skips_lead_time) {
            $leadHours = 0;
        }

        return CarbonImmutable::now('UTC')
            ->addHours($leadHours)
            ->setTimezone(config('studio.timezone'))
            ->format('Y-m-d');
    }

    #[Computed]
    public function minDate(): string
    {
        return $this->earliestSelectableDate();
    }

    public function render()
    {
        return view('livewire.booking-flow')->layout('layouts.site');
    }
}
