<?php

declare(strict_types=1);

namespace Notifier\Services;

use DateTime;
use DateTimeZone;
use DateTimeImmutable;
use InvalidArgumentException;
use RuntimeException;

/**
 * Calculates next occurrence times for recurring notifications.
 *
 * Supports 5 recurrence types:
 * - specific_months: Fire on specific months (e.g., Jan, Apr, Jul, Oct on the 15th)
 * - monthly_date: Fire on a specific day each month (e.g., 15th of every month)
 * - monthly_weekday: Fire on Nth weekday each month (e.g., 2nd Tuesday)
 * - weekly: Fire on specific weekdays (e.g., Mon, Wed, Fri)
 * - daily: Fire every N days from an anchor date
 *
 * All calculations respect the notification's timezone, then convert to UTC for storage.
 */
class RecurrenceCalculator
{
    private const MAX_ITERATIONS = 1000;

    /**
     * Calculate the next trigger time.
     *
     * @param array $recurrence Recurrence rule from database
     * @param string $timezone Notification's timezone (e.g., 'America/Mexico_City')
     * @param string|null $afterUtc Calculate next occurrence after this UTC time (null = now)
     * @return string|null UTC datetime string, or null if no more occurrences
     */
    public function calculateNext(array $recurrence, string $timezone, ?string $afterUtc = null): ?string
    {
        $this->validateRecurrence($recurrence);

        $tz = new DateTimeZone($timezone);
        $utcTz = new DateTimeZone('UTC');

        // Start from provided time or now, in the notification's timezone
        if ($afterUtc !== null) {
            $after = new DateTime($afterUtc, $utcTz);
            $after->setTimezone($tz);
        } else {
            $after = new DateTime('now', $tz);
        }

        // Check end date
        if (!empty($recurrence['end_date'])) {
            $endDate = new DateTime($recurrence['end_date'], $utcTz);
            if ($after >= $endDate) {
                return null; // Past end date
            }
        }

        $iterations = 0;
        $next = $after;

        do {
            $next = $this->computeNextOccurrence($recurrence, $next, $tz);

            if ($next === null) {
                return null; // No more occurrences (end date reached or invalid rule)
            }

            $iterations++;
            if ($iterations > self::MAX_ITERATIONS) {
                throw new RuntimeException(
                    "RecurrenceCalculator exceeded max iterations ({$iterations}). " .
                    "Check recurrence rule for type '{$recurrence['type']}'"
                );
            }

            // Check end date
            if (!empty($recurrence['end_date'])) {
                $endDate = new DateTime($recurrence['end_date'], $utcTz);
                $nextUtc = (clone $next)->setTimezone($utcTz);
                if ($nextUtc > $endDate) {
                    return null;
                }
            }

        } while ($next <= $after);

        // Convert to UTC for storage
        $next->setTimezone($utcTz);
        return $next->format('Y-m-d H:i:s');
    }

    /**
     * Get the next N occurrences (for preview in UI).
     *
     * @param array $recurrence Recurrence rule
     * @param string $timezone Notification's timezone
     * @param int $count Number of occurrences to return
     * @param string|null $afterUtc Start calculating after this UTC time
     * @return array Array of UTC datetime strings
     */
    public function getNextOccurrences(array $recurrence, string $timezone, int $count = 4, ?string $afterUtc = null): array
    {
        $occurrences = [];
        $currentAfter = $afterUtc;

        for ($i = 0; $i < $count; $i++) {
            $next = $this->calculateNext($recurrence, $timezone, $currentAfter);
            if ($next === null) {
                break;
            }
            $occurrences[] = $next;
            $currentAfter = $next;
        }

        return $occurrences;
    }

    /**
     * Validate a recurrence rule.
     *
     * @throws InvalidArgumentException if rule is invalid
     */
    public function validateRecurrence(array $recurrence): void
    {
        if (empty($recurrence['type'])) {
            throw new InvalidArgumentException('Recurrence type is required');
        }

        if (empty($recurrence['time_of_day']) || !preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $recurrence['time_of_day'])) {
            throw new InvalidArgumentException('Valid time_of_day (HH:MM) is required');
        }

        $type = $recurrence['type'];

        switch ($type) {
            case 'specific_months':
                $this->validateSpecificMonths($recurrence);
                break;
            case 'monthly_date':
                $this->validateMonthlyDate($recurrence);
                break;
            case 'monthly_weekday':
                $this->validateMonthlyWeekday($recurrence);
                break;
            case 'weekly':
                $this->validateWeekly($recurrence);
                break;
            case 'daily':
                $this->validateDaily($recurrence);
                break;
            default:
                throw new InvalidArgumentException("Invalid recurrence type: {$type}");
        }

        if (array_key_exists('weekend_adjust', $recurrence) && $recurrence['weekend_adjust'] !== null) {
            $adjust = $recurrence['weekend_adjust'];
            if (!is_string($adjust) || !in_array($adjust, ['previous_weekday'], true)) {
                throw new InvalidArgumentException('Invalid weekend_adjust value');
            }
            if ($type !== 'monthly_date') {
                throw new InvalidArgumentException('weekend_adjust is only supported for monthly_date');
            }
        }
    }

    private function validateSpecificMonths(array $recurrence): void
    {
        $months = $this->parseJsonArray($recurrence['months'] ?? null);
        if (empty($months)) {
            throw new InvalidArgumentException('specific_months requires non-empty months array');
        }
        foreach ($months as $m) {
            if (!is_int($m) || $m < 1 || $m > 12) {
                throw new InvalidArgumentException("Invalid month value: {$m}");
            }
        }

        $day = $recurrence['day_of_month'] ?? null;
        if ($day === null || $day < 1 || $day > 31) {
            throw new InvalidArgumentException('specific_months requires day_of_month (1-31)');
        }
    }

    private function validateMonthlyDate(array $recurrence): void
    {
        $day = $recurrence['day_of_month'] ?? null;
        if ($day === null || $day < 1 || $day > 31) {
            throw new InvalidArgumentException('monthly_date requires day_of_month (1-31)');
        }
    }

    private function validateMonthlyWeekday(array $recurrence): void
    {
        $weekNumber = $recurrence['week_number'] ?? null;
        if (!in_array($weekNumber, ['1', '2', '3', '4', 'last'], true)) {
            throw new InvalidArgumentException("Invalid week_number: {$weekNumber}");
        }

        $weekday = $recurrence['weekday'] ?? null;
        if ($weekday === null || $weekday < 0 || $weekday > 6) {
            throw new InvalidArgumentException('monthly_weekday requires weekday (0-6, 0=Sunday)');
        }
    }

    private function validateWeekly(array $recurrence): void
    {
        $weekdays = $this->parseJsonArray($recurrence['weekdays'] ?? null);
        if (empty($weekdays)) {
            throw new InvalidArgumentException('weekly requires non-empty weekdays array');
        }
        foreach ($weekdays as $wd) {
            if (!is_int($wd) || $wd < 0 || $wd > 6) {
                throw new InvalidArgumentException("Invalid weekday value: {$wd}");
            }
        }
    }

    private function validateDaily(array $recurrence): void
    {
        $interval = $recurrence['interval_days'] ?? null;
        if ($interval === null || $interval < 1) {
            throw new InvalidArgumentException('daily requires interval_days >= 1');
        }

        if (empty($recurrence['anchor_date'])) {
            throw new InvalidArgumentException('daily requires anchor_date');
        }
    }

    /**
     * Compute the next occurrence after the given datetime.
     */
    private function computeNextOccurrence(array $recurrence, DateTime $after, DateTimeZone $tz): ?DateTime
    {
        $type = $recurrence['type'];
        [$hour, $minute] = explode(':', $recurrence['time_of_day']);
        $hour = (int) $hour;
        $minute = (int) $minute;

        switch ($type) {
            case 'specific_months':
                return $this->nextSpecificMonths($recurrence, $after, $tz, $hour, $minute);
            case 'monthly_date':
                return $this->nextMonthlyDate($recurrence, $after, $tz, $hour, $minute);
            case 'monthly_weekday':
                return $this->nextMonthlyWeekday($recurrence, $after, $tz, $hour, $minute);
            case 'weekly':
                return $this->nextWeekly($recurrence, $after, $tz, $hour, $minute);
            case 'daily':
                return $this->nextDaily($recurrence, $after, $tz, $hour, $minute);
            default:
                return null;
        }
    }

    /**
     * Next occurrence for specific_months type.
     */
    private function nextSpecificMonths(array $recurrence, DateTime $after, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        $months = $this->parseJsonArray($recurrence['months']);
        sort($months);
        $dayOfMonth = (int) $recurrence['day_of_month'];
        $weekendAdjust = $recurrence['weekend_adjust'] ?? null;

        $year = (int) $after->format('Y');
        $currentMonth = (int) $after->format('n');

        // Try current year first, then next year
        for ($yearOffset = 0; $yearOffset <= 1; $yearOffset++) {
            $targetYear = $year + $yearOffset;

            foreach ($months as $month) {
                // Skip months we've already passed this year
                if ($yearOffset === 0 && $month < $currentMonth) {
                    continue;
                }

                $next = $this->createDateWithDayClamp($targetYear, $month, $dayOfMonth, $hour, $minute, $tz);
                $next = $this->applyWeekendAdjust($next, $weekendAdjust, $tz, $hour, $minute);

                if ($next > $after) {
                    return $next;
                }
            }
        }

        // Wrap to first month of year after next
        $month = $months[0];
        $next = $this->createDateWithDayClamp($year + 2, $month, $dayOfMonth, $hour, $minute, $tz);
        return $this->applyWeekendAdjust($next, $weekendAdjust, $tz, $hour, $minute);
    }

    /**
     * Next occurrence for monthly_date type.
     */
    private function nextMonthlyDate(array $recurrence, DateTime $after, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        $dayOfMonth = (int) $recurrence['day_of_month'];
        $weekendAdjust = $recurrence['weekend_adjust'] ?? null;

        $year = (int) $after->format('Y');
        $month = (int) $after->format('n');

        // Try current month
        $next = $this->createDateWithDayClamp($year, $month, $dayOfMonth, $hour, $minute, $tz);
        $next = $this->applyWeekendAdjust($next, $weekendAdjust, $tz, $hour, $minute);
        if ($next > $after) {
            return $next;
        }

        // Next month
        $month++;
        if ($month > 12) {
            $month = 1;
            $year++;
        }

        $next = $this->createDateWithDayClamp($year, $month, $dayOfMonth, $hour, $minute, $tz);
        return $this->applyWeekendAdjust($next, $weekendAdjust, $tz, $hour, $minute);
    }

    /**
     * Next occurrence for monthly_weekday type (e.g., 2nd Tuesday).
     */
    private function nextMonthlyWeekday(array $recurrence, DateTime $after, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        $weekNumber = $recurrence['week_number'];
        $weekday = (int) $recurrence['weekday'];

        $year = (int) $after->format('Y');
        $month = (int) $after->format('n');

        // Try current month
        $next = $this->getNthWeekdayOfMonth($year, $month, $weekNumber, $weekday, $hour, $minute, $tz);
        if ($next !== null && $next > $after) {
            return $next;
        }

        // Try next 13 months (to handle edge cases)
        for ($i = 1; $i <= 13; $i++) {
            $month++;
            if ($month > 12) {
                $month = 1;
                $year++;
            }

            $next = $this->getNthWeekdayOfMonth($year, $month, $weekNumber, $weekday, $hour, $minute, $tz);
            if ($next !== null) {
                return $next;
            }
        }

        throw new RuntimeException("Could not find next monthly_weekday occurrence");
    }

    /**
     * Get the Nth weekday of a month (e.g., 2nd Tuesday).
     *
     * @return DateTime|null Null if the Nth weekday doesn't exist in this month
     */
    private function getNthWeekdayOfMonth(int $year, int $month, string $weekNumber, int $weekday, int $hour, int $minute, DateTimeZone $tz): ?DateTime
    {
        // Get first day of month
        $firstDay = new DateTime("{$year}-{$month}-01", $tz);
        $lastDay = (int) $firstDay->format('t');

        // Find the first occurrence of the target weekday
        $firstDayWeekday = (int) $firstDay->format('w'); // 0=Sun, 6=Sat
        $daysUntilWeekday = ($weekday - $firstDayWeekday + 7) % 7;
        $firstOccurrence = 1 + $daysUntilWeekday;

        if ($weekNumber === 'last') {
            // Find last occurrence
            $day = $firstOccurrence;
            while ($day + 7 <= $lastDay) {
                $day += 7;
            }
        } else {
            $n = (int) $weekNumber;
            $day = $firstOccurrence + (($n - 1) * 7);

            // Check if this day exists in the month
            if ($day > $lastDay) {
                return null; // e.g., 5th Tuesday doesn't exist
            }
        }

        return $this->createDateTime($year, $month, $day, $hour, $minute, $tz);
    }

    /**
     * Next occurrence for weekly type.
     */
    private function nextWeekly(array $recurrence, DateTime $after, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        $weekdays = $this->parseJsonArray($recurrence['weekdays']);
        sort($weekdays);

        $currentWeekday = (int) $after->format('w');
        $currentDate = clone $after;

        // Check each day for the next 8 days
        for ($offset = 0; $offset <= 7; $offset++) {
            $checkWeekday = ($currentWeekday + $offset) % 7;

            if (in_array($checkWeekday, $weekdays, true)) {
                $next = clone $after;
                $next->modify("+{$offset} days");
                $next->setTime($hour, $minute, 0);

                // Handle DST
                $next = $this->adjustForDst($next, $hour, $minute, $tz);

                if ($next > $after) {
                    return $next;
                }
            }
        }

        // Shouldn't reach here with valid weekdays
        throw new RuntimeException("Could not find next weekly occurrence");
    }

    /**
     * Next occurrence for daily type.
     */
    private function nextDaily(array $recurrence, DateTime $after, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        $interval = (int) $recurrence['interval_days'];
        $anchor = new DateTime($recurrence['anchor_date'], $tz);
        $anchor->setTime($hour, $minute, 0);

        // Calculate days since anchor
        $afterDate = clone $after;
        $afterDate->setTime(0, 0, 0);

        $anchorDate = clone $anchor;
        $anchorDate->setTime(0, 0, 0);

        $diff = $anchorDate->diff($afterDate);
        $daysDiff = (int) $diff->format('%r%a'); // Signed days

        // Find next multiple of interval
        if ($daysDiff < 0) {
            // After is before anchor, next occurrence is anchor
            return $this->createDateTime(
                (int) $anchor->format('Y'),
                (int) $anchor->format('n'),
                (int) $anchor->format('j'),
                $hour,
                $minute,
                $tz
            );
        }

        $currentMultiple = (int) floor($daysDiff / $interval);

        // Check if current multiple is still in the future
        $daysToAddCurrent = $currentMultiple * $interval;
        $candidate = clone $anchorDate;
        $candidate->modify("+{$daysToAddCurrent} days");
        $candidate = $this->createDateTime(
            (int) $candidate->format('Y'),
            (int) $candidate->format('n'),
            (int) $candidate->format('j'),
            $hour,
            $minute,
            $tz
        );

        if ($candidate > $after) {
            return $candidate;
        }

        // Next interval
        $daysToAdd = ($currentMultiple + 1) * $interval;
        $next = clone $anchorDate;
        $next->modify("+{$daysToAdd} days");

        return $this->createDateTime(
            (int) $next->format('Y'),
            (int) $next->format('n'),
            (int) $next->format('j'),
            $hour,
            $minute,
            $tz
        );
    }

    /**
     * Create a DateTime with day clamped to month's last day if necessary.
     */
    private function createDateWithDayClamp(int $year, int $month, int $day, int $hour, int $minute, DateTimeZone $tz): DateTime
    {
        // Get last day of month
        $lastDay = (int) (new DateTime("{$year}-{$month}-01", $tz))->format('t');
        $actualDay = min($day, $lastDay);

        return $this->createDateTime($year, $month, $actualDay, $hour, $minute, $tz);
    }

    /**
     * Create a DateTime handling DST transitions.
     */
    private function createDateTime(int $year, int $month, int $day, int $hour, int $minute, DateTimeZone $tz): DateTime
    {
        $dt = new DateTime("{$year}-{$month}-{$day} {$hour}:{$minute}:00", $tz);
        return $this->adjustForDst($dt, $hour, $minute, $tz);
    }

    /**
     * Adjust a date if it falls on a weekend.
     */
    private function applyWeekendAdjust(DateTime $dt, ?string $adjust, DateTimeZone $tz, int $hour, int $minute): DateTime
    {
        if ($adjust !== 'previous_weekday') {
            return $dt;
        }

        $weekday = (int) $dt->format('w'); // 0=Sun, 6=Sat
        if ($weekday === 6) {
            $dt->modify('-1 day');
        } elseif ($weekday === 0) {
            $dt->modify('-2 days');
        }

        return $this->createDateTime(
            (int) $dt->format('Y'),
            (int) $dt->format('n'),
            (int) $dt->format('j'),
            $hour,
            $minute,
            $tz
        );
    }

    /**
     * Adjust for DST if the requested time doesn't exist or is ambiguous.
     *
     * - Spring forward (nonexistent time): shift to next valid time
     * - Fall back (ambiguous time): use first occurrence
     */
    private function adjustForDst(DateTime $dt, int $hour, int $minute, DateTimeZone $tz): DateTime
    {
        // Check if the time we set is what we got
        $actualHour = (int) $dt->format('G');
        $actualMinute = (int) $dt->format('i');

        if ($actualHour !== $hour || $actualMinute !== $minute) {
            // DST transition occurred - PHP already adjusted to a valid time
            // This handles spring forward automatically
        }

        return $dt;
    }

    /**
     * Parse a JSON array from string or return as-is if already an array.
     */
    private function parseJsonArray($value): array
    {
        if ($value === null) {
            return [];
        }
        if (is_array($value)) {
            return $value;
        }
        if (is_string($value)) {
            $decoded = json_decode($value, true);
            return is_array($decoded) ? $decoded : [];
        }
        return [];
    }
}
