<?php

namespace App\Services\Forecasting;

use Carbon\Carbon;
use Carbon\CarbonInterface;

/**
 * UK England-and-Wales bank holiday calendar.
 *
 * Computed deterministically from year (no external dependency, no
 * hardcoded list to maintain).
 *
 * Includes the eight statutory holidays:
 *   New Year's Day, Good Friday, Easter Monday,
 *   Early May Bank Holiday, Spring Bank Holiday, Summer Bank Holiday,
 *   Christmas Day, Boxing Day
 *
 * Substitution rules: when a fixed-date holiday falls on a weekend,
 * it's observed on the next non-holiday weekday (cascades for
 * Christmas+Boxing landing on Sat+Sun).
 */
final class UkBankHolidays
{
    /**
     * Sorted list of bank holiday dates for a year, after substitution.
     *
     * @return array<int, Carbon>
     */
    public static function forYear(int $year): array
    {
        $dates = [];

        // Easter-anchored
        [$em, $ed] = self::easter($year);
        $easter = Carbon::create($year, $em, $ed);
        $dates[] = $easter->copy()->subDays(2); // Good Friday
        $dates[] = $easter->copy()->addDay();   // Easter Monday

        // Floating Mondays
        $dates[] = self::firstMondayOf($year, 5);
        $dates[] = self::lastMondayOf($year, 5);
        $dates[] = self::lastMondayOf($year, 8);

        // Fixed dates with substitution
        $dates[] = self::substituteForward(Carbon::create($year, 1, 1), $dates);
        $christmas = self::substituteForward(Carbon::create($year, 12, 25), $dates);
        $dates[] = $christmas;
        $boxing = self::substituteForward(Carbon::create($year, 12, 26), $dates);
        $dates[] = $boxing;

        usort($dates, fn (CarbonInterface $a, CarbonInterface $b): int => $a->getTimestamp() <=> $b->getTimestamp());

        return $dates;
    }

    /**
     * Is there a UK bank holiday in [$from, $from + $daysAhead - 1]?
     */
    public static function holidayWithin(CarbonInterface $from, int $daysAhead): bool
    {
        $end = $from->copy()->addDays($daysAhead - 1);
        $years = array_unique([(int) $from->format('Y'), (int) $end->format('Y')]);

        foreach ($years as $year) {
            foreach (self::forYear($year) as $holiday) {
                if ($holiday->betweenIncluded($from, $end)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Anonymous Gregorian algorithm for Easter Sunday.
     *
     * @return array{0: int, 1: int} [month, day]
     */
    private static function easter(int $year): array
    {
        $a = $year % 19;
        $b = intdiv($year, 100);
        $c = $year % 100;
        $d = intdiv($b, 4);
        $e = $b % 4;
        $f = intdiv($b + 8, 25);
        $g = intdiv($b - $f + 1, 3);
        $h = (19 * $a + $b - $d - $g + 15) % 30;
        $i = intdiv($c, 4);
        $k = $c % 4;
        $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
        $m = intdiv($a + 11 * $h + 22 * $l, 451);
        $month = intdiv($h + $l - 7 * $m + 114, 31);
        $day = (($h + $l - 7 * $m + 114) % 31) + 1;

        return [$month, $day];
    }

    private static function firstMondayOf(int $year, int $month): Carbon
    {
        $d = Carbon::create($year, $month, 1);
        while ($d->dayOfWeek !== Carbon::MONDAY) {
            $d->addDay();
        }

        return $d;
    }

    private static function lastMondayOf(int $year, int $month): Carbon
    {
        $d = Carbon::create($year, $month, 1)->endOfMonth()->startOfDay();
        while ($d->dayOfWeek !== Carbon::MONDAY) {
            $d->subDay();
        }

        return $d;
    }

    /**
     * If $candidate falls on a weekend or collides with an already-claimed
     * date, return the next non-weekend non-claimed date. Christmas/Boxing
     * cascade is handled because we pass in the running list.
     *
     * @param  array<int, CarbonInterface>  $taken
     */
    private static function substituteForward(Carbon $candidate, array $taken): Carbon
    {
        $d = $candidate->copy();
        while (true) {
            $isWeekend = in_array($d->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY], true);
            $isTaken = false;
            foreach ($taken as $t) {
                if ($t->isSameDay($d)) {
                    $isTaken = true;
                    break;
                }
            }
            if (! $isWeekend && ! $isTaken) {
                return $d;
            }
            $d->addDay();
        }
    }
}
