<?php

declare(strict_types=1);

// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace OCA\Pantry\Service;

use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Recur\RRuleIterator;

/**
 * Thin wrapper around sabre/vobject's RRuleIterator.
 */
class RecurrenceService {
	/**
	 * Validate an RRULE string (RFC 5545). Accepts either a bare rule ("FREQ=WEEKLY;INTERVAL=1")
	 * or a full "RRULE:..." line.
	 *
	 * @throws \InvalidArgumentException if the rule is malformed.
	 */
	public function validate(string $rrule): void {
		$normalized = $this->normalize($rrule);
		$this->preflight($normalized);
		try {
			new RRuleIterator($normalized, new \DateTimeImmutable('2000-01-01T00:00:00Z'));
		} catch (InvalidDataException $e) {
			throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
		}
	}

	/**
	 * Compute the next occurrence strictly after $from.
	 *
	 * The iterator's semantics are: the first item equals DTSTART; subsequent items are
	 * successive occurrences per the rule. We seed with DTSTART = $from and advance once.
	 */
	public function computeNextOccurrence(string $rrule, \DateTimeImmutable $from): ?\DateTimeImmutable {
		$normalized = $this->normalize($rrule);
		$this->preflight($normalized);
		try {
			$iter = new RRuleIterator($normalized, $from);
		} catch (InvalidDataException $e) {
			throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
		}

		// First call yields DTSTART itself. Advance to the next one.
		$iter->next();
		if (!$iter->valid()) {
			return null;
		}
		$current = $iter->current();
		if (!$current instanceof \DateTimeInterface) {
			return null;
		}
		return \DateTimeImmutable::createFromInterface($current);
	}

	/**
	 * Compute the next occurrence of a rule strictly after $after, using $dtStart as the
	 * schedule anchor.
	 *
	 * This is the "fixed schedule" semantics: the series of occurrences is determined by
	 * $dtStart (e.g. the item creation time), and we skip forward until we find the first
	 * one that is still in the future. Caps iteration to avoid runaway loops on malformed
	 * rules.
	 */
	public function nextOccurrenceAfter(
		string $rrule,
		\DateTimeImmutable $dtStart,
		\DateTimeImmutable $after,
	): ?\DateTimeImmutable {
		$normalized = $this->normalize($rrule);
		$this->preflight($normalized);
		try {
			$iter = new RRuleIterator($normalized, $dtStart);
		} catch (InvalidDataException $e) {
			throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e);
		}

		$guard = 0;
		while ($iter->valid() && $guard < 10_000) {
			$current = $iter->current();
			if ($current instanceof \DateTimeInterface) {
				if ($current->getTimestamp() > $after->getTimestamp()) {
					return \DateTimeImmutable::createFromInterface($current);
				}
			}
			$iter->next();
			$guard++;
		}
		return null;
	}

	private function normalize(string $rrule): string {
		$trim = trim($rrule);
		if (stripos($trim, 'RRULE:') === 0) {
			return substr($trim, 6);
		}
		return $trim;
	}

	/**
	 * Shallow structural check before the string ever reaches sabre/vobject.
	 *
	 * Why this exists: sabre/vobject 4.5.x on some PHP 8.2 / doctrine combinations raises a
	 * PHP deprecation while parsing malformed input, which bubbles up to PHPUnit as a
	 * "deprecation" and fails CI under `--fail-on-warning`. Rejecting obvious garbage here
	 * keeps the error path within our own code.
	 *
	 * @throws \InvalidArgumentException if the rule is not a well-formed list of KEY=VALUE
	 *                                   parts or lacks a supported FREQ= clause.
	 */
	private function preflight(string $rrule): void {
		if ($rrule === '') {
			throw new \InvalidArgumentException('Invalid RRULE: empty rule');
		}
		// Whole rule must be KEY=VALUE(;KEY=VALUE)*
		if (!preg_match('/^[A-Z][A-Z0-9-]*=[^;]*(?:;[A-Z][A-Z0-9-]*=[^;]*)*$/i', $rrule)) {
			throw new \InvalidArgumentException('Invalid RRULE: expected KEY=VALUE parts separated by ";"');
		}
		// Must contain a supported FREQ clause.
		if (!preg_match('/(?:^|;)FREQ=(SECONDLY|MINUTELY|HOURLY|DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)/i', $rrule)) {
			throw new \InvalidArgumentException('Invalid RRULE: missing or unsupported FREQ clause');
		}
	}
}
