<?php

namespace App\ScheduledTasks;

use Carbon\Carbon;
use Illuminate\Support\Collection;
use Mtc\MercuryDataModels\ScheduledTask;

/**
 * Schedule computation for scheduled tasks. Deliberately separate from the legacy
 * app/Schedule classes (which are typed to Report|ExportMap and stay untouched);
 * parity with them is proven by test, not by reuse.
 *
 * All times are interpreted in the platform's server timezone, matching export maps.
 * day_of_week uses ISO numbering (1 = Monday .. 7 = Sunday).
 */
class ScheduledTaskClock
{
    /**
     * The next moment a schedule-triggered task is due after $from, or null when the
     * task cannot be scheduled (paused, non-schedule trigger, or no times configured).
     */
    public function nextDueAt(ScheduledTask $task, Carbon $from): ?Carbon
    {
        if (!$this->isSchedulable($task)) {
            return null;
        }

        $candidates = collect();
        $cursor = $from->copy()->startOfDay();

        // 62 days covers the worst case (monthly on a day the next month lacks)
        for ($day = 0; $day <= 62; $day++) {
            $date = $cursor->copy()->addDays($day);
            $dayOccurrences = $this->occurrencesForDate($task, $date)
                ->filter(fn (Carbon $occurrence) => $occurrence->greaterThan($from));

            if ($dayOccurrences->isNotEmpty()) {
                $candidates = $dayOccurrences;
                break;
            }
        }

        return $candidates->sort()->first();
    }

    /**
     * Every occurrence of the task on the given date, unioned across all schedule
     * rules and de-duplicated (two rules landing on the same minute yield one run).
     *
     * @return Collection<int, Carbon>
     */
    public function occurrencesForDate(ScheduledTask $task, Carbon $date): Collection
    {
        if (!$this->isSchedulable($task)) {
            return collect();
        }

        return collect($task->schedule_rules)
            ->filter(fn (array $rule) => $this->ruleDueOnDate($rule, $task, $date))
            ->map(fn (array $rule) => $date->copy()->setTimeFromTimeString($rule['time']))
            ->unique(fn (Carbon $occurrence) => $occurrence->format('H:i'))
            ->sort()
            ->values();
    }

    private function isSchedulable(ScheduledTask $task): bool
    {
        return $task->enabled
            && $task->trigger_type === ScheduledTask::TRIGGER_SCHEDULE
            && !empty($task->schedule_rules);
    }

    /**
     * @param array{frequency: string, day_of_week?: int|null, day_of_month?: int|null, time: string} $rule
     */
    private function ruleDueOnDate(array $rule, ScheduledTask $task, Carbon $date): bool
    {
        if (empty($rule['frequency']) || empty($rule['time'])) {
            return false;
        }

        return match ($rule['frequency']) {
            'daily' => true,
            'weekdays' => $date->isoWeekday() >= 1 && $date->isoWeekday() <= 5,
            'weekly' => $date->isoWeekday() === (int) ($rule['day_of_week'] ?? 0),
            'biweekly' => $date->isoWeekday() === (int) ($rule['day_of_week'] ?? 0)
                && $this->biweeklyAnchorAllows($task, $date),
            'monthly' => $date->day === $this->clampedDayOfMonth($rule, $date),
            default => false,
        };
    }

    /**
     * Biweekly runs anchor to the last run: a due weekday within 8 days of the
     * previous run is the "off" week and is skipped.
     */
    private function biweeklyAnchorAllows(ScheduledTask $task, Carbon $date): bool
    {
        if (!$task->last_run_at) {
            return true;
        }

        return $date->copy()->startOfDay()
            ->diffInDays($task->last_run_at->copy()->startOfDay(), true) >= 8;
    }

    /**
     * Day-of-month clamped to the length of the given month (31st in June runs on the 30th).
     */
    private function clampedDayOfMonth(array $rule, Carbon $date): int
    {
        return min((int) ($rule['day_of_month'] ?? 1), $date->copy()->endOfMonth()->day);
    }
}
