<?php

declare(strict_types=1);

namespace App\Services\Booking;

use App\Enums\BookingStatus;
use App\Enums\BookingType;
use App\Enums\SessionType;
use App\Exceptions\SlotUnavailableException;
use App\Mail\BookingConfirmedMail;
use App\Mail\BookingHeldMail;
use App\Mail\BookingReleasedMail;
use App\Models\Booking;
use App\Models\Experience;
use App\Models\PackagePurchase;
use App\Models\Room;
use App\Models\RoomAddon;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;

/**
 * Creates and cancels bookings.
 *
 * The one thing to understand about this class: it does not prevent double
 * bookings by checking whether a slot is free. Checking is inherently racy —
 * two requests can both read "free" microseconds apart and both proceed.
 *
 * Prevention comes from the unique index on booking_slots. We attempt the
 * insert and let the database arbitrate; the loser gets a constraint
 * violation, its transaction rolls back whole, and it is told the time has
 * gone. The advisory check that runs first exists only to give a clearer
 * message in the common, uncontended case.
 */
class BookingService
{
    public function __construct(
        private readonly SlotCalculator $calculator,
        private readonly AvailabilityService $availability,
        private readonly PricingService $pricing,
        private readonly ReferenceGenerator $references,
    ) {}

    /**
     * @param  array<int, array{addon: RoomAddon, quantity?: int}>  $addons
     *
     * @throws SlotUnavailableException
     */
    public function create(
        User $user,
        Room $room,
        CarbonImmutable $startsAt,
        int $slotCount,
        ?SessionType $sessionType = null,
        BookingType $type = BookingType::Session,
        array $addons = [],
        ?PackagePurchase $package = null,
        ?int $preferredEngineerId = null,
        ?int $guestCount = null,
        ?Experience $experience = null,
        ?CarbonImmutable $now = null,
    ): Booking {
        $now ??= CarbonImmutable::now('UTC');
        $startsAt = $startsAt->setTimezone('UTC');

        $this->guardRequest($room, $startsAt, $slotCount, $type, $package, $sessionType, $now);
        $this->guardExperience($experience, $room, $slotCount, $guestCount);

        // Advisory only. The unique index is what actually decides.
        if (! $this->availability->canAccommodate($room, $startsAt, $slotCount, $sessionType, $package, $now)) {
            throw SlotUnavailableException::taken();
        }

        $quote = $this->pricing->quote($room, $slotCount, $sessionType, $addons, $package, $experience, $guestCount);
        $staffRequired = $this->staffRequired($room, $sessionType, $type, $package);
        $slotStarts = $this->calculator->slotsForBooking($startsAt, $slotCount);
        $endsAt = $this->calculator->endOfBooking($startsAt, $slotCount);

        try {
            $booking = DB::transaction(function () use (
                $user, $room, $startsAt, $endsAt, $slotCount, $slotStarts, $sessionType,
                $type, $quote, $package, $staffRequired, $preferredEngineerId, $guestCount,
                $experience, $now
            ) {
                // Clear any lapsed hold still squatting on these exact slots.
                // Availability already ignores them, but their rows would trip
                // the unique index, so they have to go before we insert.
                $this->releaseLapsedHolds($room, $slotStarts, $now);

                $booking = Booking::create([
                    'reference' => $this->references->forBooking($user, $startsAt),
                    'user_id' => $user->id,
                    'room_id' => $room->id,
                    'experience_id' => $experience?->id,
                    'type' => $type,
                    'session_type' => $sessionType,
                    'status' => $quote->paidByPackage
                        ? BookingStatus::Confirmed
                        : BookingStatus::PendingPayment,
                    'starts_at' => $startsAt,
                    'ends_at' => $endsAt,
                    'slot_count' => $slotCount,
                    'staff_required' => $staffRequired,
                    'preferred_engineer_id' => $preferredEngineerId,
                    'price_total' => $quote->total,
                    'room_subtotal' => $quote->roomSubtotal,
                    'addons_subtotal' => $quote->addonsSubtotal,
                    'package_purchase_id' => $package?->id,
                    'studio_slots_used' => $quote->studioSlotsUsed,
                    'engineer_slots_used' => $quote->engineerSlotsUsed,
                    'guest_count' => $guestCount,
                    // A package booking is confirmed outright; only a booking
                    // awaiting money carries a countdown.
                    'hold_expires_at' => $quote->paidByPackage
                        ? null
                        : $now->addMinutes((int) config('studio.hold_minutes')),
                    'confirmed_at' => $quote->paidByPackage ? $now : null,
                ]);

                $this->claimSlots($booking, $room, $slotStarts, $staffRequired);

                if ($package !== null) {
                    $this->debitPackage($package, $quote);
                }

                foreach ($quote->addons as $line) {
                    $booking->addons()->create($line);
                }

                return $booking;
            }, 3);

            // After the commit, never inside it. A rolled-back transaction
            // that had already sent "your session is held" would leave a
            // customer chasing a booking that does not exist.
            $this->notifyHeld($booking);

            return $booking;
        } catch (UniqueConstraintViolationException) {
            // Someone else claimed at least one of these slots between our
            // advisory check and our insert. The whole transaction rolled
            // back, so there is nothing to clean up.
            //
            // Logged because reaching here means a genuine race actually
            // happened, which is worth being able to see in production — both
            // to confirm the guard is doing real work and to spot if it starts
            // firing often enough to be worth smoothing over in the UI.
            Log::info('Booking lost a race for its slots.', [
                'room_id' => $room->id,
                'starts_at' => $startsAt->toIso8601String(),
                'slot_count' => $slotCount,
                'user_id' => $user->id,
            ]);

            throw SlotUnavailableException::taken();
        }
    }

    /**
     * Free a booking's slots and return any package balance it consumed.
     *
     * Deleting the slot rows rather than leaving them keeps the unique index
     * as the single source of truth about occupancy — a cancelled booking has
     * no rows, so nothing has to remember to filter it out.
     */
    public function cancel(
        Booking $booking,
        string $reason = '',
        ?string $refundDue = null,
        ?CarbonImmutable $now = null,
    ): Booking {
        $now ??= CarbonImmutable::now('UTC');

        return DB::transaction(function () use ($booking, $reason, $refundDue, $now) {
            $booking->slots()->delete();

            if ($booking->package_purchase_id !== null) {
                $this->creditPackage($booking);
            }

            $booking->update([
                'status' => BookingStatus::Cancelled,
                'cancelled_at' => $now,
                'cancellation_reason' => $reason ?: null,
                'refund_due' => $refundDue,
            ]);

            return $booking->refresh();
        });
    }

    /**
     * "Your session is held" — sent inline, not queued.
     *
     * It carries a thirty minute deadline and the payment reference, and on
     * shared hosting the queue drains from cron every five minutes at best.
     * Spending a sixth of the customer's window in a queue is not acceptable.
     *
     * A booking paid from a package owes nothing, so there is no deadline and
     * nothing to send.
     */
    private function notifyHeld(Booking $booking): void
    {
        if ($booking->isPaidByPackage()) {
            return;
        }

        try {
            Mail::to($booking->user->email)->send(new BookingHeldMail($booking));
        } catch (\Throwable $e) {
            // A mail outage must not lose a booking the customer has already
            // paid for or is about to. The slot is held either way, and the
            // details are on screen in front of them.
            report($e);
        }
    }

    /**
     * Move a booking to a new time, keeping its slot rows honest.
     *
     * The reason this belongs in the service rather than being an editable
     * field on a form: starts_at is display data, but booking_slots is the
     * occupancy record. Editing the time without rewriting the slot rows
     * would leave the booking showing one time while still blocking another,
     * and silently free a slot nobody can see is free.
     *
     * The move claims its new slots the same way a fresh booking does, so it
     * is subject to the same unique index and cannot land on top of someone.
     *
     * @throws SlotUnavailableException
     */
    public function reschedule(
        Booking $booking,
        CarbonImmutable $newStart,
        ?int $slotCount = null,
        ?CarbonImmutable $now = null,
    ): Booking {
        $now ??= CarbonImmutable::now('UTC');
        $newStart = $newStart->setTimezone('UTC');
        $slotCount ??= $booking->slot_count;

        $room = $booking->room;

        $this->guardRequest(
            $room,
            $newStart,
            $slotCount,
            $booking->type,
            $booking->packagePurchase,
            $booking->session_type,
            $now,
        );

        // Ignore this booking's own slots when checking, or a move of one
        // slot would collide with itself.
        if (! $this->availability->canAccommodate(
            $room, $newStart, $slotCount, $booking->session_type,
            $booking->packagePurchase, $now, $booking,
        )) {
            throw SlotUnavailableException::taken();
        }

        $slotStarts = $this->calculator->slotsForBooking($newStart, $slotCount);

        try {
            return DB::transaction(function () use ($booking, $room, $newStart, $slotCount, $slotStarts, $now) {
                // Release the old claim first so a partial overlap between the
                // old and new times does not collide with itself.
                $booking->slots()->delete();

                $this->releaseLapsedHolds($room, $slotStarts, $now);
                $this->claimSlots($booking, $room, $slotStarts, $booking->staff_required);

                $booking->update([
                    'starts_at' => $newStart,
                    'ends_at' => $this->calculator->endOfBooking($newStart, $slotCount),
                    'slot_count' => $slotCount,
                    'reschedule_count' => $booking->reschedule_count + 1,
                ]);

                return $booking->refresh();
            }, 3);
        } catch (UniqueConstraintViolationException) {
            throw SlotUnavailableException::taken();
        }
    }

    /**
     * Confirm a booking whose payment has been settled.
     *
     * Deliberately does not touch the payment rows. Settling money is
     * PaymentService's job — it already knows whether this was a card
     * payment that cleared or a transfer an admin verified, and having two
     * services both writing payment status is how a booking ends up
     * confirmed against a payment that was never marked paid.
     */
    public function confirm(Booking $booking, ?User $by = null, ?CarbonImmutable $now = null): Booking
    {
        $now ??= CarbonImmutable::now('UTC');

        $booking->update([
            'status' => BookingStatus::Confirmed,
            'confirmed_at' => $now,
            // Nothing is owed any more, so the countdown stops mattering.
            'hold_expires_at' => null,
        ]);

        $booking->refresh();

        // Queued: the customer already saw "confirmed" on screen, so this is
        // a receipt rather than a race.
        Mail::to($booking->user->email)->send(new BookingConfirmedMail($booking));

        return $booking;
    }

    /**
     * Mark a session as having taken place.
     *
     * This is what the loyalty counter counts — it separates sessions that
     * actually happened from ones merely paid for.
     */
    public function complete(Booking $booking, ?CarbonImmutable $now = null): Booking
    {
        $now ??= CarbonImmutable::now('UTC');

        return DB::transaction(function () use ($booking, $now) {
            $booking->update([
                'status' => BookingStatus::Completed,
                'completed_at' => $now,
            ]);

            $booking->user->profile?->increment('completed_bookings_count');

            return $booking->refresh();
        });
    }

    /**
     * What the studio owes back if this booking is cancelled now.
     *
     * 48 hours or more notice is a full refund; under that, half. Returned as
     * a decimal string for the admin to action manually — payment is a bank
     * transfer, so the system records the amount but never moves the money.
     */
    public function refundDueFor(Booking $booking, ?CarbonImmutable $now = null): string
    {
        $now ??= CarbonImmutable::now('UTC');

        if ($booking->isPaidByPackage()) {
            return '0.00';
        }

        $paid = (string) $booking->price_total;

        if (bccomp($paid, '0.00', 2) === 0) {
            return '0.00';
        }

        $hoursNotice = $now->diffInHours($booking->starts_at, false);

        if ($hoursNotice < 0) {
            return '0.00';
        }

        return $hoursNotice >= 48
            ? $paid
            : bcdiv($paid, '2', 2);
    }

    /**
     * Expire holds that have run out.
     *
     * Correctness does not depend on this running on time: availability
     * already ignores a lapsed hold, and create() clears any stale rows in
     * its own way. This is bookkeeping — it settles statuses so the admin
     * panel reads correctly and the release email has something to fire on.
     *
     * @return int number of bookings expired
     */
    public function expireLapsedHolds(?CarbonImmutable $now = null): int
    {
        $now ??= CarbonImmutable::now('UTC');
        $expired = 0;

        Booking::query()
            ->lapsed($now)
            ->chunkById(100, function ($bookings) use (&$expired, $now) {
                foreach ($bookings as $booking) {
                    DB::transaction(function () use ($booking, $now) {
                        $booking->slots()->delete();

                        if ($booking->package_purchase_id !== null) {
                            $this->creditPackage($booking);
                        }

                        $booking->update([
                            'status' => BookingStatus::Expired,
                            'cancelled_at' => $now,
                        ]);
                    });

                    // Queued. Telling somebody their slot went is not urgent
                    // in the way holding it was, and a mail outage must not
                    // stop the sweep freeing rooms.
                    Mail::to($booking->user->email)->send(new BookingReleasedMail($booking));

                    $expired++;
                }
            });

        return $expired;
    }

    /**
     * Write one row per occupied slot. This is the moment the booking becomes
     * exclusive, and the only place a conflict can be detected.
     *
     * @param  array<int, CarbonImmutable>  $slotStarts
     */
    private function claimSlots(Booking $booking, Room $room, array $slotStarts, int $staffRequired): void
    {
        $rows = [];

        foreach ($slotStarts as $slotStart) {
            $rows[] = [
                'booking_id' => $booking->id,
                'room_id' => $room->id,
                'slot_start' => $slotStart->format('Y-m-d H:i:s'),
                'staff_required' => $staffRequired,
            ];
        }

        // A single multi-row insert, so the whole claim succeeds or fails as
        // one statement rather than partially landing.
        DB::table('booking_slots')->insert($rows);
    }

    /**
     * Remove slot rows belonging to holds that have already lapsed.
     *
     * Scoped to the room and the exact slots being claimed, so this is a
     * narrow, self-healing cleanup rather than a table-wide sweep. Lapsed
     * holds elsewhere are harmless — availability excludes them — and will be
     * tidied either by cron or by whoever next books that time.
     *
     * @param  array<int, CarbonImmutable>  $slotStarts
     */
    private function releaseLapsedHolds(Room $room, array $slotStarts, CarbonImmutable $now): void
    {
        $formatted = array_map(
            fn (CarbonImmutable $slot) => $slot->format('Y-m-d H:i:s'),
            $slotStarts,
        );

        $lapsedIds = DB::table('booking_slots')
            ->join('bookings', 'bookings.id', '=', 'booking_slots.booking_id')
            ->where('booking_slots.room_id', $room->id)
            ->whereIn('booking_slots.slot_start', $formatted)
            ->where('bookings.status', BookingStatus::PendingPayment->value)
            ->where('bookings.hold_expires_at', '<=', $now)
            ->pluck('bookings.id')
            ->unique()
            ->all();

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

        DB::table('booking_slots')->whereIn('booking_id', $lapsedIds)->delete();

        Booking::query()->whereIn('id', $lapsedIds)->update([
            'status' => BookingStatus::Expired,
            'cancelled_at' => $now,
            'updated_at' => $now,
        ]);
    }

    /**
     * Deduct from the package, refusing to go negative.
     *
     * The conditional UPDATE is what makes this safe under concurrency: two
     * bookings racing to spend the last hour will both pass an in-PHP check,
     * but only one can satisfy the WHERE clause. Zero rows affected means we
     * lost, and the transaction unwinds.
     */
    private function debitPackage(PackagePurchase $package, Quote $quote): void
    {
        $query = PackagePurchase::query()
            ->whereKey($package->id)
            ->where('studio_slots_remaining', '>=', $quote->studioSlotsUsed);

        if ($quote->engineerSlotsUsed > 0) {
            $query->where('engineer_slots_remaining', '>=', $quote->engineerSlotsUsed);
        }

        $affected = $query->update([
            'studio_slots_remaining' => DB::raw('studio_slots_remaining - '.(int) $quote->studioSlotsUsed),
            'engineer_slots_remaining' => DB::raw('engineer_slots_remaining - '.(int) $quote->engineerSlotsUsed),
        ]);

        if ($affected === 0) {
            throw SlotUnavailableException::because(
                'Your package does not have enough hours left for this session.'
            );
        }
    }

    /** Give back what a cancelled or expired booking had taken. */
    private function creditPackage(Booking $booking): void
    {
        PackagePurchase::query()
            ->whereKey($booking->package_purchase_id)
            ->update([
                'studio_slots_remaining' => DB::raw('studio_slots_remaining + '.(int) $booking->studio_slots_used),
                'engineer_slots_remaining' => DB::raw('engineer_slots_remaining + '.(int) $booking->engineer_slots_used),
            ]);
    }

    /**
     * How many staff this booking takes off the rota for its whole length.
     *
     * Zero for self-serve sessions and anything in Studio C. One for a normal
     * engineer-led session and for the Birthday Experience, which includes an
     * engineer throughout. Two where the package promises a second person —
     * Label Ready's vocal producer, Industry VIP's assistant.
     */
    private function staffRequired(
        Room $room,
        ?SessionType $sessionType,
        BookingType $type,
        ?PackagePurchase $package,
    ): int {
        if (! $room->takes_engineer) {
            return 0;
        }

        if ($type === BookingType::Birthday) {
            return 1;
        }

        if ($sessionType !== SessionType::WithEngineer) {
            return 0;
        }

        return max(1, $package?->staff_per_session ?? 1);
    }

    /**
     * Everything that can be rejected before we touch the database.
     *
     * @throws SlotUnavailableException
     */
    private function guardRequest(
        Room $room,
        CarbonImmutable $startsAt,
        int $slotCount,
        BookingType $type,
        ?PackagePurchase $package,
        ?SessionType $sessionType,
        CarbonImmutable $now,
    ): void {
        if (! $room->is_active) {
            throw SlotUnavailableException::because('That room is not currently bookable.');
        }

        if ($slotCount < 1) {
            throw SlotUnavailableException::because('A booking must cover at least one slot.');
        }

        if (! $this->calculator->isOnBoundary($startsAt)) {
            throw SlotUnavailableException::because(
                'Sessions start on the hour or the half hour.'
            );
        }

        if ($room->isDayRate()) {
            $this->guardDayRateRoom($room, $slotCount, $type);
        } elseif ($slotCount < $room->minimumSlots()) {
            throw SlotUnavailableException::because(
                "The minimum session in {$room->name} is {$room->min_hours} hours."
            );
        }

        if ($package !== null) {
            $this->guardPackage($room, $slotCount, $package, $sessionType, $now);
        }
    }

    /**
     * An experience has a fixed shape: its own room, its own length, and a
     * party size the studio has committed to being able to handle.
     *
     * @throws SlotUnavailableException
     */
    private function guardExperience(
        ?Experience $experience,
        Room $room,
        int $slotCount,
        ?int $guestCount,
    ): void {
        if ($experience === null) {
            return;
        }

        if (! $experience->is_active) {
            throw SlotUnavailableException::because("The {$experience->name} is not currently available.");
        }

        if ($experience->room_id !== $room->id) {
            throw SlotUnavailableException::because(
                "The {$experience->name} is held in {$experience->room->name}."
            );
        }

        if ($slotCount !== $experience->slotCount()) {
            throw SlotUnavailableException::because(
                "The {$experience->name} is {$experience->duration_hours} hours."
            );
        }

        if (! $experience->acceptsGuests($guestCount)) {
            throw SlotUnavailableException::because(
                "The {$experience->name} takes up to {$experience->max_guests} guests."
            );
        }
    }

    /**
     * Studio D is sold as a whole block. The only thing that may occupy it
     * for less is the Birthday Experience, which is a fixed five hours.
     */
    private function guardDayRateRoom(Room $room, int $slotCount, BookingType $type): void
    {
        if ($type === BookingType::Birthday) {
            return;
        }

        if ($slotCount !== $room->slotsPerDay()) {
            throw SlotUnavailableException::because(
                "{$room->name} is booked as a full {$room->day_length_hours} hour day."
            );
        }
    }

    private function guardPackage(
        Room $room,
        int $slotCount,
        PackagePurchase $package,
        ?SessionType $sessionType,
        CarbonImmutable $now,
    ): void {
        // $now is threaded through rather than left to default: every other
        // decision in this booking is made against the caller's clock, and a
        // guard that quietly consults the wall clock instead would pass a
        // package the rest of the flow considers expired.
        if (! $package->isUsable($now)) {
            throw SlotUnavailableException::because(
                'That package has expired or has no hours left.'
            );
        }

        // Studio D is never redeemable — it is sold as a day or not at all.
        if (! $room->package_redeemable) {
            throw SlotUnavailableException::because(
                "{$room->name} cannot be booked using a package."
            );
        }

        $needsEngineer = $room->takes_engineer && $sessionType === SessionType::WithEngineer;

        if (! $package->canCover($slotCount, $needsEngineer)) {
            throw SlotUnavailableException::because(
                $needsEngineer && $package->engineer_slots_remaining < $slotCount
                    ? 'Your package does not have enough engineer hours left for this session.'
                    : 'Your package does not have enough hours left for this session.'
            );
        }
    }
}
