<?php

declare(strict_types=1);

namespace App\Core;

use App\Core\Database as DB;

/**
 * Booking availability engine. Generates open time slots from the weekly
 * availability_rules, subtracts blackout dates and existing bookings per room,
 * and drops past times. Used by both the public /api/availability endpoint and
 * the server-side booking guard in PaymentController, so the two can never
 * disagree about whether a slot is bookable.
 */
final class Availability
{
    /** Bookings in these states no longer hold their slot. */
    private const FREED_STATES = ['cancelled', 'failed'];

    /**
     * Open slots for a date, grouped by room.
     *
     * @return array{
     *   date: string, weekday: int, slot_minutes: int, closed: bool,
     *   rooms: list<array{room_id: int, room_name: string, times: list<string>}>
     * }
     */
    public static function forDate(string $date, ?int $roomId = null): array
    {
        Schema::heal();
        $weekday = self::weekday($date);
        $slotMinutes = self::slotMinutes();

        $empty = ['date' => $date, 'weekday' => $weekday, 'slot_minutes' => $slotMinutes, 'closed' => true, 'rooms' => []];
        if ($weekday < 0) {
            return $empty;
        }

        $candidates = self::candidateTimes($weekday, $slotMinutes);
        // Whole-spa closure for the day (blackout with no specific room).
        $spaClosed = DB::selectOne(
            'SELECT id FROM blackout_dates WHERE date = ? AND room_id IS NULL',
            [$date],
        ) !== null;
        if ($candidates === [] || $spaClosed) {
            return [...$empty, 'closed' => true];
        }

        $todayCutoff = $date === date('Y-m-d') ? date('H:i') : null;

        $roomRows = $roomId !== null
            ? DB::select('SELECT id, name FROM rooms WHERE id = ? AND active = 1', [$roomId])
            : DB::select('SELECT id, name FROM rooms WHERE active = 1 ORDER BY sort_order, id');

        $rooms = [];
        foreach ($roomRows as $room) {
            $rid = (int) $room['id'];

            // Room-specific closure for the day.
            if (DB::selectOne('SELECT id FROM blackout_dates WHERE date = ? AND room_id = ?', [$date, $rid]) !== null) {
                continue;
            }

            $taken = [];
            $placeholders = implode(',', array_fill(0, count(self::FREED_STATES), '?'));
            foreach (DB::select(
                "SELECT slot_time FROM bookings
                 WHERE room_id = ? AND slot_date = ? AND status NOT IN ($placeholders)",
                [$rid, $date, ...self::FREED_STATES],
            ) as $b) {
                $taken[(string) $b['slot_time']] = true;
            }

            $times = array_values(array_filter(
                $candidates,
                static fn (string $t): bool => !isset($taken[$t]) && ($todayCutoff === null || $t > $todayCutoff),
            ));

            $rooms[] = ['room_id' => $rid, 'room_name' => (string) $room['name'], 'times' => $times];
        }

        $anyOpen = array_filter($rooms, static fn (array $r): bool => $r['times'] !== []);
        return [
            'date'         => $date,
            'weekday'      => $weekday,
            'slot_minutes' => $slotMinutes,
            'closed'       => $anyOpen === [],
            'rooms'        => $rooms,
        ];
    }

    /** True only if (room, date, time) is a genuinely open, bookable slot right now. */
    public static function isOpen(int $roomId, string $date, string $time): bool
    {
        foreach (self::forDate($date, $roomId)['rooms'] as $room) {
            if ($room['room_id'] === $roomId) {
                return in_array($time, $room['times'], true);
            }
        }
        return false;
    }

    public static function slotMinutes(): int
    {
        $row = DB::selectOne('SELECT value FROM settings WHERE name = ?', ['slot_minutes']);
        $n = (int) ($row['value'] ?? 60);
        return $n > 0 ? $n : 60;
    }

    /** 0=Sunday … 6=Saturday, or -1 when the date is malformed. */
    private static function weekday(string $date): int
    {
        if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $date, $m)
            || !checkdate((int) $m[2], (int) $m[3], (int) $m[1])) {
            return -1;
        }
        return (int) date('w', (int) strtotime($date));
    }

    /**
     * Slot start times for a weekday, stepping by $slotMinutes and keeping only
     * starts whose full appointment fits inside a rule window.
     *
     * @return list<string> zero-padded "HH:MM", sorted, de-duplicated
     */
    private static function candidateTimes(int $weekday, int $slotMinutes): array
    {
        $times = [];
        foreach (DB::select(
            'SELECT start_time, end_time FROM availability_rules WHERE weekday = ? AND active = 1',
            [$weekday],
        ) as $rule) {
            $start = self::toMinutes((string) $rule['start_time']);
            $end = self::toMinutes((string) $rule['end_time']);
            if ($start === null || $end === null) {
                continue;
            }
            for ($t = $start; $t + $slotMinutes <= $end; $t += $slotMinutes) {
                $times[sprintf('%02d:%02d', intdiv($t, 60), $t % 60)] = true;
            }
        }
        $out = array_keys($times);
        sort($out);
        return $out;
    }

    private static function toMinutes(string $hhmm): ?int
    {
        if (!preg_match('/^(\d{1,2}):(\d{2})$/', trim($hhmm), $m)) {
            return null;
        }
        $h = (int) $m[1];
        $min = (int) $m[2];
        return $h > 23 || $min > 59 ? null : $h * 60 + $min;
    }
}
