<?php

    namespace Icing\Utility;

    use ArrayIterator;
    use Cake\Chronos\ChronosDate;
    use Cake\Collection\Collection;
    use Cake\Collection\CollectionInterface;
    use Cake\I18n\Date;
    use DateInterval;
    use DateTimeInterface;
    use Exception;
    use InvalidArgumentException;
    use IteratorAggregate;
    use JsonSerializable;
    use Throwable;
    use Traversable;

    /**
     * Date range utility class
     * useful for working with date ranges better than DatePeriod
     *
     * $dateRange = new FrozenDateRange('2020-01-01', '2020-01-31');
     * $dateRange->getStart(); // returns a FrozenDate object representing the current start date, FrozenDate('2020-01-01') in this case
     * $dateRange->getEnd(); // returns a FrozenDate object representing the current start date, FrozenDate('2020-01-31') in this case
     *
     * You can also create a range based on a string similar to what flatpickr uses:
     * $dateRange = new FrozenDateRange('2020-01-01 to 2020-01-31'); // any string that contains " to " will be handled this way
     * $dateRange = FrozenDateRange::create('Jan 1 2020 to Jan 31 2020'); // same as above
     *
     * You can extend the range by adding more dates:
     * $dateRange = new FrozenDateRange('2020-01-01', '2020-01-31');
     * $dateRange->addDates('2020-01-30', new FrozenDate('2020-01-02')); // no change because these dates are not outside the current range
     * $dateRange->contains('2021-08-05'); // false
     * $dateRange->addDates('2021-12-31'); // end date is now '2021-12-31', this is an easy way to make the range based on a list of dates
     * $dateRange->contains('2021-08-05'); // true
     *
     * you can also create the range based on a list of dates:
     * $dateRange = new FrozenDateRange(['2020-01-01', '2020-01-31', '2020-12-31']);
     * $dateRange = FrozenDateRange::create(['2020-01-01', '2020-01-31', '2020-12-31']);
     *
     * you can create the range based on duration as well:
     * $dateRange = new FrozenDateRange('now', '+1 month'); // start is now and end is +1 month
     * $dateRange = new FrozenDateRange('2020-01-01', '+1 month'); // start is '2020-01-01' and end is evaluated as FrozenDate('2020-01-01')->modify('+1 month')
     *
     * And you can modify the duration, based on either the start date (default) or the end date:
     * $dateRange = new FrozenDateRange(); // start and end are now the current date
     * $dateRange->setDuration('P1M'); // start date is now and end date is +1 month
     * Including true as the 2nd argument will modify the start date instead of the end date:
     * $dateRange->setDuration('P1D', true); // start date is +1 month -1 day and end date is +1 month (end date set in prev line)
     *
     * $dateRange = FrozenDateRange::create(); // start and end are now the current date
     * $dateRange->setDuration('+1 months', false); // end date is now and start date is -1 month
     *
     * Finally, you can convert the range to a string:
     * $dateRange = new FrozenDateRange('2020-01-01', '2020-01-31');
     * $dateRange->pretty(); // returns '12:00am January 1, 2020 to 11:59pm January 31, 2020'
     * $dateRange->format('g:ia F j, Y', ' to ', 'g:ia F j, Y'); // same as above
     * $dateRange->format('F j', ' to ', 'g:ia'); // returns 'January 1 to 11:59pm'
     * $dateRange->format('F j', ' to ', 'g:ia', ['start' => 'F j', 'end' => 'g:ia']); // returns 'January 1 to 11:59pm'
     *
     */
    class DateRange implements IteratorAggregate, JsonSerializable
    {

        protected Date $start;
        protected Date $end;

        public function __construct($start = null, $end = null)
        {
            if (is_array($start)) {
                $dates = $start;
                unset($start, $end);
                $this->addDates(...$dates);
            } else {
                if (is_string($start) && str_contains($start, ' to ')) {
                    [$start, $end] = explode(' to ', $start);
                }
                $this->setStart($start);
                $this->setEnd($end);
            }
        }

        static public function create($start = null, $end = null): static
        {
            return new static($start, $end);
        }

        public function setStart(ChronosDate|DateTimeInterface|string|int|null $start): static
        {
            $this->start = new Date($start ?? $this->end);
            if (!isset($this->end)) {
                $this->end = $this->start;
            }
            return $this;
        }

        public function getStart(): Date
        {
            return $this->start;
        }

        public function setEnd(ChronosDate|DateTimeInterface|string|int|null $end): static
        {
            $this->end = new Date($end ?? $this->start);
            if (!isset($this->start)) {
                $this->start = $this->end;
            }
            return $this;
        }

        public function getEnd(): Date
        {
            return $this->end;
        }

        public function setDates(ChronosDate|DateTimeInterface|string|int ...$dates): static
        {
            unset($this->start, $this->end);
            return $this->addDates(...$dates);
        }

        public function addDates(DateTimeInterface|string|int ...$dates): static
        {
            foreach ($dates as $date) {
                $date = new Date($date);
                if (!isset($this->start) || $date->lessThan($this->start)) {
                    $this->setStart($date);
                }
                if (!isset($this->end) || $date->greaterThan($this->end)) {
                    $this->setEnd($date);
                }
            }
            return $this;
        }

        public function setDuration($duration, $fromStart = true): static
        {
            $interval = $this->createInterval($duration);
            if ($fromStart) {
                $this->setEnd($this->start->add($interval));
            } else {
                $this->setStart($this->end->sub($interval));
            }
            return $this;
        }

        public function getDuration(): DateInterval
        {
            return $this->end->diff($this->start);
        }

        public function contains(DateTimeInterface|string|int|null $date, $equal = true): bool
        {
            $date = new Date($date);
            return $date->between($this->start, $this->end, $equal);
        }

        public function pretty(): string
        {
            return $this->format(
                'g:ia F j, Y',
                ' to ',
                'g:ia F j, Y',
                [
                    'start' => ['F j', 'Y', 'g:ia'],
                    'end'   => 'g:ia'
                ]
            );
        }

        static public function createInterval(DateInterval|string|float|int $interval): DateInterval
        {
            if (is_numeric($interval)) {
                $interval = "PT{$interval}S";
            }
            if (is_string($interval)) {
                try {
                    $interval = new DateInterval($interval);
                } catch (Throwable) {
                    try {
                        $date = new Date();
                        return $date->diff($date->modify($interval));
                    } catch (Throwable) {
                        throw new InvalidArgumentException("Unable to parse interval: $interval");
                    }
                }
            }
            if ($interval instanceof DateInterval) {
                return $interval;
            }
            throw new InvalidArgumentException("Unable to parse interval: $interval");
        }

        public function getIterator($interval = null): Traversable
        {
            if ($interval === null) {
                return new ArrayIterator([$this->getStart(), $this->getEnd()]);
            } else {
                $date     = $this->getStart();
                $end      = $this->getEnd();
                $interval = $this->createInterval($interval);

                while ($date->lessthanOrEquals($end)) {
                    yield $date;
                    $date = $date->add($interval);
                }
            }
        }

        /**
         * @throws Exception
         */
        public function toArray($interval = null): array
        {
            if ($interval) {
                return iterator_to_array($this->getIterator($interval));
            }
            return [
                $this->getStart(),
                $this->getEnd()
            ];
        }

        /**
         * @throws Exception
         */
        public function toCollection($interval = null): CollectionInterface
        {
            return new Collection($this->getIterator($interval));
        }

        /**
         * @throws Exception
         */
        public function jsonSerialize(): array
        {
            return $this->toArray();
        }

        public function format(...$format): string
        {
            switch (count($format)) {
                case 1:
                    $startFormat = $endFormat = $format[0];
                    $separator   = ' to ';
                    break;
                case 2:
                    $startFormat = $format[0];
                    $endFormat   = $format[1];
                    $separator   = ' to ';
                    break;
                case 3:
                    $startFormat = $format[0];
                    $separator   = $format[1];
                    $endFormat   = $format[2];
                    break;
                case 4:
                    $startFormat  = $format[0];
                    $separator    = $format[1];
                    $endFormat    = $format[2];
                    $reduceFormat = $format[3];
                    if (!is_array($reduceFormat) || (empty($reduceFormat['start']) && empty($reduceFormat['end']))) {
                        $reduceFormat = ['start' => (array)$reduceFormat];
                    }
                    break;
                default:
                    $startFormat = $endFormat = 'Y-m-d H:i:s';
                    $separator   = ' to ';
            }

            if (!empty($reduceFormat)) {
                foreach ($reduceFormat as $target => $formats) {
                    foreach ((array)$formats as $f) {
                        $f1 = $this->start->format($f);
                        $f2 = $this->end->format($f);
                        if ($f1 === $f2) {
                            switch ($target) {
                                case 'start':
                                    $startFormat = preg_replace("/[^a-zA-Z0-9]*{$f}[^a-zA-Z0-9]*/", '', $startFormat);
                                    break;
                                case 'end':
                                    $endFormat = preg_replace("/[^a-zA-Z0-9]*{$f}[^a-zA-Z0-9]*/", '', $endFormat);
                                    break;
                            }
                        }
                    }
                }
            }

            return $this->start->format($startFormat) . $separator . $this->end->format($endFormat);
        }

        public function __toString(): string
        {
            return $this->format('Y-m-d H:i:s');
        }

        public function nice($separator = ' - ') {

            $y1 = $this->start->format('Y');
            $y2 = $this->end->format('Y');

            $m1 = $this->start->format('M');
            $m2 = $this->end->format('M');

            $d1 = $this->start->format('j');
            $d2 = $this->end->format('j');

            if ($y1 != $y2) {
                return $this->format('M j, Y', $separator , 'M j, Y');
            }
            if ($m1 != $m2) {
                return $this->format('M j', $separator , 'M j, Y');
            }
            if ($d1 != $d2) {
                return $this->format('M j', $separator , 'j, Y');
            }
            return $this->start->format('M j, Y');
        }

    }
