<?php

namespace MintyPHP\Service\Scheduler;

class ScheduleCalculator
{
    public function normalizeTimezone(?string $timezone): string
    {
        $timezone = trim((string) $timezone);
        if ($timezone === '') {
            $timezone = defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC';
        }
        try {
            new \DateTimeZone($timezone);
            return $timezone;
        } catch (\Throwable $exception) {
            return defined('APP_TIMEZONE') ? (string) APP_TIMEZONE : 'UTC';
        }
    }

    public function normalizeScheduleType(?string $type): string
    {
        $type = strtolower(trim((string) $type));
        return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily';
    }

    public function normalizeInterval(string $type, int $value): int
    {
        $type = $this->normalizeScheduleType($type);
        if ($type === 'hourly') {
            return max(1, min(24, $value));
        }
        if ($type === 'weekly') {
            return max(1, min(52, $value));
        }
        return max(1, min(365, $value));
    }

    public function normalizeTime(?string $time): ?string
    {
        $time = trim((string) $time);
        if ($time === '') {
            return null;
        }
        if (!preg_match('/^(\d{1,2}):(\d{2})$/', $time, $matches)) {
            return null;
        }
        $hour = (int) $matches[1];
        $minute = (int) $matches[2];
        if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
            return null;
        }
        return sprintf('%02d:%02d', $hour, $minute);
    }

    public function normalizeWeekdaysCsv(mixed $value): ?string
    {
        $list = $this->parseWeekdays($value);
        if (!$list) {
            return null;
        }
        return implode(',', $list);
    }

    /**
     * @return array<int>
     */
    public function parseWeekdays(mixed $value): array
    {
        $raw = [];
        if (is_array($value)) {
            $raw = $value;
        } else {
            $raw = explode(',', (string) $value);
        }

        $weekdays = [];
        foreach ($raw as $item) {
            $day = (int) trim((string) $item);
            if ($day >= 1 && $day <= 7) {
                $weekdays[$day] = true;
            }
        }

        $list = array_keys($weekdays);
        sort($list, SORT_NUMERIC);
        return $list;
    }

    /**
     * Calculates the next UTC execution time for a scheduled job based on its schedule
     * configuration and a reference point in time.
     *
     * Expected keys in $job:
     *   - schedule_type         (string)  'hourly'|'daily'|'weekly'
     *   - schedule_interval     (int)     1..24 for hourly, 1..365 for daily, 1..52 for weekly
     *   - timezone              (string)  IANA timezone name (e.g. 'Europe/Berlin')
     *   - schedule_time         (string)  'HH:MM', required for daily and weekly
     *   - schedule_weekdays_csv (string)  CSV of ISO weekday numbers 1 (Mon)..7 (Sun), required for weekly
     *
     * All schedule calculations are performed in the job's local timezone to correctly
     * handle DST transitions. The returned value is always UTC.
     *
     * Returns null if the schedule is misconfigured (e.g. missing schedule_time for daily/weekly)
     * or if no valid next run could be found within the search window.
     */
    public function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable
    {
        $referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
        $type = $this->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
        $interval = $this->normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1));
        $timezone = new \DateTimeZone($this->normalizeTimezone((string) ($job['timezone'] ?? '')));
        $localReference = $referenceUtc->setTimezone($timezone);

        if ($type === 'hourly') {
            // Snap to the top of the current hour, then advance by the interval.
            $candidate = $localReference
                ->setTime((int) $localReference->format('H'), 0, 0)
                ->modify('+' . $interval . ' hour');
            return $candidate->setTimezone(new \DateTimeZone('UTC'));
        }

        $time = $this->normalizeTime((string) ($job['schedule_time'] ?? ''));
        if ($time === null) {
            return null;
        }
        [$hours, $minutes] = array_map('intval', explode(':', $time));

        if ($type === 'daily') {
            $candidate = $localReference->setTime($hours, $minutes, 0);
            if ($candidate <= $localReference) {
                $candidate = $candidate->modify('+' . $interval . ' day');
            }
            return $candidate->setTimezone(new \DateTimeZone('UTC'));
        }

        // Weekly: iterate forward day by day until we find a matching weekday in the correct
        // week interval. The search window is 1110 days (~3 years), which comfortably covers
        // the maximum weekly interval of 52 weeks × 7 days/week = 364 days, with margin for
        // weekday alignment and DST edge cases.
        $weekdays = $this->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? ''));
        if (!$weekdays) {
            $weekdays = [1];
        }

        $startOfDay = $localReference->setTime(0, 0, 0);

        // 1970-01-05 is a Monday in any timezone offset that does not cross the date line,
        // used as a stable epoch to count week indices for "every N weeks" interval logic.
        $epochMonday = new \DateTimeImmutable('1970-01-05 00:00:00', $timezone);
        for ($i = 0; $i < 370 * 3; $i++) {
            $day = $startOfDay->modify('+' . $i . ' day');
            $isoDay = (int) $day->format('N');
            if (!in_array($isoDay, $weekdays, true)) {
                continue;
            }

            $daysFromEpoch = (int) $epochMonday->diff($day)->format('%r%a');
            $weekIndex = (int) floor($daysFromEpoch / 7);
            if ($interval > 1 && ($weekIndex % $interval) !== 0) {
                continue;
            }

            $candidate = $day->setTime($hours, $minutes, 0);
            if ($candidate <= $localReference) {
                continue;
            }
            return $candidate->setTimezone(new \DateTimeZone('UTC'));
        }

        return null;
    }
}
