<?php

namespace ipl\Scheduler;

use BadMethodCallException;
use DateTime;
use DateTimeInterface;
use DateTimeZone;
use Generator;
use InvalidArgumentException;
use ipl\Scheduler\Contract\Frequency;
use Recurr\Exception\InvalidRRule;
use Recurr\Rule as RecurrRule;
use Recurr\Transformer\ArrayTransformer;
use Recurr\Transformer\ArrayTransformerConfig;
use Recurr\Transformer\Constraint\AfterConstraint;
use Recurr\Transformer\Constraint\BetweenConstraint;
use stdClass;

use function ipl\Stdlib\get_php_type;

/**
 * iCalendar recurrence rule-based scheduling frequency
 *
 * Schedules a task based on a recurrence rule in iCalendar format (RFC 5545).
 * Supports common frequencies via the {@see RRule::MINUTELY}, {@see RRule::HOURLY},
 * {@see RRule::DAILY}, {@see RRule::WEEKLY}, {@see RRule::MONTHLY}, and
 * {@see RRule::YEARLY} constants, or arbitrary RRULE strings for fine-grained control.
 * Supports optional start and end bounds via {@see RRule::startAt()} and {@see RRule::endAt()},
 * and an optional display timezone via {@see RRule::setTimezone()}.
 */
class RRule implements Frequency
{
    /** @var string Run once a year */
    public const YEARLY = 'YEARLY';

    /** @var string Run every 3 month starting from the given start time */
    public const QUARTERLY = 'QUARTERLY';

    /** @var string Run once a month */
    public const MONTHLY = 'MONTHLY';

    /** @var string Run once a week based on the specified start time */
    public const WEEKLY = 'WEEKLY';

    /** @var string Run once a day at the specified start time */
    public const DAILY = 'DAILY';

    /** @var string Run once an hour */
    public const HOURLY = 'HOURLY';

    /** @var string Run once a minute */
    public const MINUTELY = 'MINUTELY';

    /** @var int Default limit of the recurrences to be generated by the transformer */
    private const DEFAULT_LIMIT = 1;

    /** @var RecurrRule Underlying recurrence rule object */
    protected RecurrRule $rrule;

    /** @var ArrayTransformer Transformer that expands the rule into concrete occurrences */
    protected ArrayTransformer $transformer;

    /** @var ArrayTransformerConfig Configuration for the transformer, including the virtual limit */
    protected ArrayTransformerConfig $transformerConfig;

    /** @var string Human-readable frequency label, e.g. one of the class constants */
    protected string $frequency;

    /**
     * Create a new RRule
     *
     * @param string|array<string, mixed> $rule
     *
     * @throws InvalidRRule
     */
    public function __construct(array|string $rule)
    {
        $this->rrule = new RecurrRule($rule);
        $this->frequency = $this->rrule->getFreqAsText();
        $this->transformerConfig = new ArrayTransformerConfig();
        $this->transformerConfig->setVirtualLimit(self::DEFAULT_LIMIT);

        // If no specific run day is set, enable the "last-day-of-month fix"
        // to avoid skipping months without days 29, 30, or 31.
        if (
            $this->getFrequency() === static::MONTHLY
            && ! $this->rrule->getByDay()
            && ! $this->rrule->getByMonthDay()
        ) {
            $this->transformerConfig->enableLastDayOfMonthFix();
        }

        $this->transformer = new ArrayTransformer($this->transformerConfig);
    }

    /**
     * Create an {@see RRule} instance from its stored JSON representation
     *
     * The JSON must decode to an object with an `rrule` key and a `frequency` key,
     * and optionally a `start` key formatted according to {@see Frequency::SERIALIZED_DATETIME_FORMAT}.
     *
     * @param string $json
     *
     * @return static
     *
     * @throws InvalidArgumentException If the start datetime cannot be deserialized
     */
    public static function fromJson(string $json): static
    {
        /** @var stdClass $data */
        $data = json_decode($json);
        $self = new static($data->rrule);
        $self->frequency = $data->frequency;
        if (isset($data->start)) {
            $start = DateTime::createFromFormat(static::SERIALIZED_DATETIME_FORMAT, $data->start);
            if (! $start) {
                throw new InvalidArgumentException(sprintf('Cannot deserialize start time: %s', $data->start));
            }

            $self->startAt($start);
        }

        return $self;
    }

    /**
     * Create an RRule instance from one of the predefined frequency constants
     *
     * @param string $frequency One of the class frequency constants
     *
     * @return static
     *
     * @throws InvalidArgumentException If the frequency is not one of the known constants
     */
    public static function fromFrequency(string $frequency): static
    {
        $frequencies = array_flip([
            static::MINUTELY,
            static::HOURLY,
            static::DAILY,
            static::WEEKLY,
            static::MONTHLY,
            static::QUARTERLY,
            static::YEARLY
        ]);

        if (! isset($frequencies[$frequency])) {
            throw new InvalidArgumentException(sprintf('Unknown frequency provided: %s', $frequency));
        }

        if ($frequency === static::QUARTERLY) {
            $repeat = static::MONTHLY;
            $rule = "FREQ=$repeat;INTERVAL=3";
        } else {
            $rule = "FREQ=$frequency";
        }

        $self = new static($rule);
        $self->frequency = $frequency;

        return $self;
    }

    public function isDue(DateTimeInterface $dateTime): bool
    {
        if ($dateTime < $this->rrule->getStartDate() || $this->isExpired($dateTime)) {
            return false;
        }

        $nextDue = $this->getNextRecurrences($dateTime);
        if (! $nextDue->valid()) {
            return false;
        }

        return $nextDue->current() == $dateTime;
    }

    /**
     * Get the next due date relative to the given time
     *
     * Returns the end time if the frequency is expired, or the given time itself if no further recurrence exists.
     *
     * @param DateTimeInterface $dateTime
     *
     * @return DateTimeInterface
     */
    public function getNextDue(DateTimeInterface $dateTime): DateTimeInterface
    {
        if ($this->isExpired($dateTime)) {
            return $this->getEnd();
        }

        $nextDue = $this->getNextRecurrences($dateTime, 1, false);
        if (! $nextDue->valid()) {
            return $dateTime;
        }

        return $nextDue->current();
    }

    public function isExpired(DateTimeInterface $dateTime): bool
    {
        if ($this->rrule->repeatsIndefinitely()) {
            return false;
        }

        return $this->getEnd() !== null && $this->getEnd() < $dateTime;
    }

    public function getStart(): ?DateTimeInterface
    {
        return $this->rrule->getStartDate();
    }

    /**
     * Get the end time of this frequency
     *
     * Returns the rule's end date if set, falling back to the `UNTIL` value.
     *
     * @return ?DateTimeInterface
     */
    public function getEnd(): ?DateTimeInterface
    {
        return $this->rrule->getEndDate() ?? $this->rrule->getUntil();
    }

    /**
     * Set timezone for the rrule
     *
     * @param string $timezone
     *
     * @return $this
     */
    public function setTimezone(string $timezone): static
    {
        $this->rrule->setTimezone($timezone);

        $this->rrule->setStartDate($this->alignTimezone($this->rrule->getStartDate()));
        $this->rrule->setEndDate($this->alignTimezone($this->rrule->getEndDate()));
        $until = $this->rrule->getUntil();
        if ($until !== null) {
            $this->rrule->setUntil($this->alignTimezone($until));
        }

        return $this;
    }

    /**
     * Set the start time of this frequency
     *
     * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second.
     *
     * @param DateTimeInterface $start
     *
     * @return $this
     */
    public function startAt(DateTimeInterface $start): static
    {
        $this->rrule->setStartDate($this->normalizeDateTime($start));

        return $this;
    }

    /**
     * Set the time until this frequency lasts
     *
     * The given datetime will be cloned and microseconds removed since iCalendar datetimes only work to the second.
     *
     * @param DateTimeInterface $end
     *
     * @return $this
     */
    public function endAt(DateTimeInterface $end): static
    {
        $this->rrule->setUntil($this->normalizeDateTime($end));

        return $this;
    }

    /**
     * Get the frequency of this rule
     *
     * @return string
     */
    public function getFrequency(): string
    {
        return $this->frequency;
    }

    /**
     * Get a set of recurrences relative to the given time
     *
     * @param DateTimeInterface $dateTime
     * @param int $limit Limit the recurrences to be generated to the given value
     * @param bool $include Whether to include the passed time in the result set
     *
     * @return Generator<DateTimeInterface>
     */
    public function getNextRecurrences(
        DateTimeInterface $dateTime,
        int $limit = self::DEFAULT_LIMIT,
        bool $include = true
    ): Generator {
        // The virtual limit is Recurr's guard against infinite loops: it caps
        // the total number of candidates processed. It is shared config, so we
        // raise it temporarily for multi-result calls and restore it afterward.
        $resetTransformerConfig = function (int $limit = self::DEFAULT_LIMIT): void {
            $this->transformerConfig->setVirtualLimit($limit);
            $this->transformer->setConfig($this->transformerConfig);
        };

        if ($limit > self::DEFAULT_LIMIT) {
            $resetTransformerConfig($limit);
        }

        // For bounded rules, BetweenConstraint also caps at the end time.
        // AfterConstraint alone would let the transformer run past it.
        $constraint = new AfterConstraint($dateTime, $include);
        if (! $this->rrule->repeatsIndefinitely()) {
            $constraint = new BetweenConstraint($dateTime, $this->getEnd(), $include);
        }

        // By default, non-matching candidates count toward the virtual limit too.
        // With a past start date this exhausts it before any match is found.
        // Pass false so only matches count.
        $recurrences = $this->transformer->transform($this->rrule, $constraint, countConstraintFailures: false);
        foreach ($recurrences as $recurrence) {
            /** @var \Recurr\Recurrence $recurrence */

            // Return results in the caller's timezone, not the rrule's.
            yield DateTime::createFromInterface($recurrence->getStart())->setTimezone($dateTime->getTimezone());
        }

        if ($limit > self::DEFAULT_LIMIT) {
            $resetTransformerConfig();
        }
    }

    /**
     * Serialize this frequency to a JSON-encodable array
     *
     * Always includes `rrule` and `frequency` keys; `start` is included only when set,
     * formatted according to {@see Frequency::SERIALIZED_DATETIME_FORMAT}.
     *
     * @return array<string, string>
     */
    public function jsonSerialize(): array
    {
        $data = [
            'rrule'     => $this->rrule->getString(RecurrRule::TZ_FIXED),
            'frequency' => $this->frequency
        ];

        $start = $this->getStart();
        if ($start) {
            $data['start'] = $start->format(static::SERIALIZED_DATETIME_FORMAT);
        }

        return $data;
    }

    /**
     * Redirect all public method calls to the underlying rrule object
     *
     * @param string $methodName
     * @param array<mixed> $args
     *
     * @return mixed
     *
     * @throws BadMethodCallException If the given method doesn't exist or when setter method is called
     */
    public function __call(string $methodName, array $args)
    {
        if (! method_exists($this->rrule, $methodName)) {
            throw new BadMethodCallException(
                sprintf('Call to undefined method %s::%s()', get_php_type($this->rrule), $methodName)
            );
        }

        if (strtolower(substr($methodName, 0, 3)) !== 'get') {
            throw new BadMethodCallException(
                sprintf('Dynamic method %s is not supported. Only getters (get*) are', $methodName)
            );
        }

        return call_user_func_array([$this->rrule, $methodName], $args);
    }

    /**
     * Normalize a datetime for Recurr
     *
     * The given datetime is cloned, stripped of microseconds, and aligned to
     * the rrule's configured timezone.
     *
     * @param DateTimeInterface $dateTime
     *
     * @return DateTime
     */
    protected function normalizeDateTime(DateTimeInterface $dateTime): DateTime
    {
        $dateTime = DateTime::createFromInterface($dateTime);
        // Microseconds in the datetime can cause the first recurrence to be
        // skipped, as the transformer only operates to the second.
        // See upstream issue #155.
        $dateTime->setTime(
            (int) $dateTime->format('H'),
            (int) $dateTime->format('i'),
            (int) $dateTime->format('s')
        );

        return $this->alignTimezone($dateTime);
    }

    /**
     * Align a datetime to the rrule's configured timezone
     *
     * Recurr requires all dates to share the rrule's own timezone, otherwise
     * recurrences are calculated against the wrong UTC offset.
     *
     * @param ?DateTimeInterface $dateTime
     *
     * @return ?DateTime
     */
    protected function alignTimezone(?DateTimeInterface $dateTime): ?DateTime
    {
        if ($dateTime === null) {
            return null;
        }

        return DateTime::createFromInterface($dateTime)
            ->setTimezone(new DateTimeZone($this->rrule->getTimezone()));
    }
}
