<?php

declare(strict_types=1);

namespace PpPhp\Core\Calendar;

/**
 * CalendarRecurrence — a compact iCal RRULE expander.
 *
 * Given a base start (and optional end) and an RRULE string, it yields the
 * concrete occurrences that fall inside a [from,to] window. Supports the rules
 * a calendar UI realistically produces:
 *   FREQ=DAILY|WEEKLY|MONTHLY|YEARLY, INTERVAL, COUNT, UNTIL, BYDAY (MO,TU,…).
 *
 * Each occurrence keeps the base event's duration, so a 2-hour meeting stays
 * 2 hours on every repeat. Bounded by a hard cap so a runaway rule can never
 * loop forever.
 */
final class CalendarRecurrence
{
    private const CAP = 750;

    private const DAY_MAP = ['SU' => 0, 'MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6];

    /**
     * @return list<array{start:string,end:?string}>  occurrences within [from,to]
     */
    public static function expand(string $rrule, string $baseStart, ?string $baseEnd, string $from, string $to): array
    {
        $rule = self::parse($rrule);
        if (($rule['FREQ'] ?? '') === '') {
            // Not recurring — the single instance, if in range.
            return self::inRange($baseStart, $baseEnd, $from, $to) ? [['start' => $baseStart, 'end' => $baseEnd]] : [];
        }

        $baseTs = strtotime($baseStart);
        if ($baseTs === false) return [];
        $duration = ($baseEnd && strtotime($baseEnd) !== false) ? (strtotime($baseEnd) - $baseTs) : 0;

        $freq = strtoupper((string) $rule['FREQ']);
        $interval = max(1, (int) ($rule['INTERVAL'] ?? 1));
        $count = isset($rule['COUNT']) ? (int) $rule['COUNT'] : null;
        $until = isset($rule['UNTIL']) ? strtotime((string) $rule['UNTIL']) : null;
        $byday = [];
        if (!empty($rule['BYDAY'])) {
            foreach (explode(',', (string) $rule['BYDAY']) as $d) {
                $d = strtoupper(trim($d));
                if (isset(self::DAY_MAP[$d])) $byday[] = self::DAY_MAP[$d];
            }
        }

        $fromTs = strtotime($from . ' 00:00:00') ?: $baseTs;
        $toTs = strtotime($to . ' 23:59:59') ?: ($baseTs + 86400 * 365);
        // Never scan more than a bounded horizon past the window.
        $horizon = min($toTs, $until ?: PHP_INT_MAX);

        $out = [];
        $emitted = 0;
        $iter = 0;

        // WEEKLY with BYDAY needs per-weekday emission; other freqs step the base.
        if ($freq === 'WEEKLY' && $byday) {
            $weekStart = strtotime('sunday last week', $baseTs); // anchor
            $cursorWeek = $baseTs;
            while ($iter++ < self::CAP * 2) {
                $weekBase = strtotime(date('Y-m-d H:i:s', $cursorWeek));
                foreach ($byday as $dow) {
                    $dayTs = self::weekdayInWeek($cursorWeek, $dow, $baseTs);
                    if ($dayTs < $baseTs) continue;
                    if ($until && $dayTs > $until) { return $out; }
                    if ($count !== null && $emitted >= $count) { return $out; }
                    $emitted++;
                    if ($dayTs >= $fromTs && $dayTs <= $toTs) {
                        $out[] = self::mk($dayTs, $duration);
                        if (count($out) >= self::CAP) return $out;
                    }
                }
                $cursorWeek = strtotime('+' . (7 * $interval) . ' days', $cursorWeek);
                if ($cursorWeek > $horizon + 86400 * 7) break;
            }
            usort($out, fn($a, $b) => strcmp($a['start'], $b['start']));
            return $out;
        }

        $cur = $baseTs;
        while ($iter++ < self::CAP * 3) {
            if ($until && $cur > $until) break;
            if ($count !== null && $emitted >= $count) break;
            $emitted++;
            if ($cur >= $fromTs && $cur <= $toTs) {
                $out[] = self::mk($cur, $duration);
                if (count($out) >= self::CAP) break;
            }
            if ($cur > $horizon) break;
            $cur = self::step($cur, $freq, $interval);
            if ($cur === null) break;
        }
        return $out;
    }

    private static function mk(int $ts, int $duration): array
    {
        return [
            'start' => date('Y-m-d H:i:s', $ts),
            'end'   => $duration > 0 ? date('Y-m-d H:i:s', $ts + $duration) : null,
        ];
    }

    private static function step(int $ts, string $freq, int $interval): ?int
    {
        switch ($freq) {
            case 'DAILY':   return strtotime('+' . $interval . ' days', $ts);
            case 'WEEKLY':  return strtotime('+' . ($interval * 7) . ' days', $ts);
            case 'MONTHLY': return strtotime('+' . $interval . ' months', $ts);
            case 'YEARLY':  return strtotime('+' . $interval . ' years', $ts);
            default:        return null;
        }
    }

    /** The timestamp of $dow within the ISO week containing $weekRef, at base time-of-day. */
    private static function weekdayInWeek(int $weekRef, int $dow, int $baseTs): int
    {
        $ref = new \DateTime('@' . $weekRef);
        $ref->setTimezone(new \DateTimeZone(date_default_timezone_get()));
        $curDow = (int) $ref->format('w');
        $delta = $dow - $curDow;
        $day = strtotime(($delta >= 0 ? '+' : '') . $delta . ' days', $weekRef);
        // Apply the base event's time-of-day.
        return strtotime(date('Y-m-d', $day) . ' ' . date('H:i:s', $baseTs));
    }

    private static function inRange(string $start, ?string $end, string $from, string $to): bool
    {
        $s = strtotime($start);
        $fromTs = strtotime($from . ' 00:00:00');
        $toTs = strtotime($to . ' 23:59:59');
        $e = $end ? strtotime($end) : $s;
        return $s !== false && $e >= $fromTs && $s <= $toTs;
    }

    /** Parse "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE" into an assoc array. */
    public static function parse(string $rrule): array
    {
        $rrule = trim($rrule);
        if ($rrule === '') return [];
        $rrule = preg_replace('/^RRULE:/i', '', $rrule);
        $out = [];
        foreach (explode(';', $rrule) as $part) {
            if (strpos($part, '=') === false) continue;
            [$k, $v] = explode('=', $part, 2);
            $out[strtoupper(trim($k))] = trim($v);
        }
        return $out;
    }
}
