<?php

declare(strict_types=1);

/**
 * This file is part of CodeIgniter 4 framework.
 *
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace CodeIgniter\I18n;

use CodeIgniter\I18n\Exceptions\I18nException;
use DateInterval;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use IntlCalendar;
use IntlDateFormatter;
use Locale;

/**
 * This trait has properties and methods for Time and TimeLegacy.
 * When TimeLegacy is removed, this will be in Time.
 */
trait TimeTrait
{
    /**
     * @var DateTimeZone|string
     */
    protected $timezone;

    /**
     * @var string
     */
    protected $locale;

    /**
     * Format to use when displaying datetime through __toString
     *
     * @var string
     */
    protected $toStringFormat = 'yyyy-MM-dd HH:mm:ss';

    /**
     * Used to check time string to determine if it is relative time or not....
     *
     * @var string
     */
    protected static $relativePattern = '/this|next|last|tomorrow|yesterday|midnight|today|[+-]|first|last|ago/i';

    /**
     * @var DateTimeInterface|static|null
     */
    protected static $testNow;

    // --------------------------------------------------------------------
    // Constructors
    // --------------------------------------------------------------------

    /**
     * Time constructor.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @throws Exception
     */
    public function __construct(?string $time = null, $timezone = null, ?string $locale = null)
    {
        $this->locale = in_array($locale, [null, '', '0'], true) ? Locale::getDefault() : $locale;

        $time ??= '';

        // If a test instance has been provided, use it instead.
        if ($time === '' && static::$testNow instanceof static) {
            if ($timezone !== null) {
                $testNow = static::$testNow->setTimezone($timezone);
                $time    = $testNow->format('Y-m-d H:i:s.u');
            } else {
                $timezone = static::$testNow->getTimezone();
                $time     = static::$testNow->format('Y-m-d H:i:s.u');
            }
        }

        $timezone       = $timezone ?: date_default_timezone_get();
        $this->timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone);

        // If the time string was a relative string (i.e. 'next Tuesday')
        // then we need to adjust the time going in so that we have a current
        // timezone to work with.
        if ($time !== '' && static::hasRelativeKeywords($time)) {
            $instance = new DateTime('now', $this->timezone);
            $instance->modify($time);
            $time = $instance->format('Y-m-d H:i:s.u');
        }

        parent::__construct($time, $this->timezone);
    }

    /**
     * Returns a new Time instance with the timezone set.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function now($timezone = null, ?string $locale = null)
    {
        return new static(null, $timezone, $locale);
    }

    /**
     * Returns a new Time instance while parsing a datetime string.
     *
     * Example:
     *  $time = Time::parse('first day of December 2008');
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function parse(string $datetime, $timezone = null, ?string $locale = null)
    {
        return new static($datetime, $timezone, $locale);
    }

    /**
     * Return a new time with the time set to midnight.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function today($timezone = null, ?string $locale = null)
    {
        return new static(date('Y-m-d 00:00:00'), $timezone, $locale);
    }

    /**
     * Returns an instance set to midnight yesterday morning.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function yesterday($timezone = null, ?string $locale = null)
    {
        return new static(date('Y-m-d 00:00:00', strtotime('-1 day')), $timezone, $locale);
    }

    /**
     * Returns an instance set to midnight tomorrow morning.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function tomorrow($timezone = null, ?string $locale = null)
    {
        return new static(date('Y-m-d 00:00:00', strtotime('+1 day')), $timezone, $locale);
    }

    /**
     * Returns a new instance based on the year, month and day. If any of those three
     * are left empty, will default to the current value.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function createFromDate(?int $year = null, ?int $month = null, ?int $day = null, $timezone = null, ?string $locale = null)
    {
        return static::create($year, $month, $day, null, null, null, $timezone, $locale);
    }

    /**
     * Returns a new instance with the date set to today, and the time set to the values passed in.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function createFromTime(?int $hour = null, ?int $minutes = null, ?int $seconds = null, $timezone = null, ?string $locale = null)
    {
        return static::create(null, null, null, $hour, $minutes, $seconds, $timezone, $locale);
    }

    /**
     * Returns a new instance with the date time values individually set.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @return static
     *
     * @throws Exception
     */
    public static function create(
        ?int $year = null,
        ?int $month = null,
        ?int $day = null,
        ?int $hour = null,
        ?int $minutes = null,
        ?int $seconds = null,
        $timezone = null,
        ?string $locale = null,
    ) {
        $year ??= date('Y');
        $month ??= date('m');
        $day ??= date('d');
        $hour ??= 0;
        $minutes ??= 0;
        $seconds ??= 0;

        return new static(date('Y-m-d H:i:s', strtotime("{$year}-{$month}-{$day} {$hour}:{$minutes}:{$seconds}")), $timezone, $locale);
    }

    /**
     * Provides a replacement for DateTime's own createFromFormat function, that provides
     * more flexible timeZone handling
     *
     * @psalm-external-mutation-free
     *
     * @param string                   $format
     * @param string                   $datetime
     * @param DateTimeZone|string|null $timezone
     *
     * @throws Exception
     */
    public static function createFromFormat($format, $datetime, $timezone = null): static
    {
        if (! $date = parent::createFromFormat($format, $datetime)) {
            throw I18nException::forInvalidFormat($format);
        }

        return new static($date->format('Y-m-d H:i:s.u'), $timezone);
    }

    /**
     * Returns a new instance with the datetime set based on the provided UNIX timestamp.
     *
     * @param DateTimeZone|string|null $timezone
     *
     * @throws Exception
     */
    public static function createFromTimestamp(float|int $timestamp, $timezone = null, ?string $locale = null): static
    {
        $time = new static(sprintf('@%.6F', $timestamp), 'UTC', $locale);

        $timezone ??= 'UTC';

        return $time->setTimezone($timezone);
    }

    /**
     * Takes an instance of DateTimeInterface and returns an instance of Time with it's same values.
     *
     * @return static
     *
     * @throws Exception
     */
    public static function createFromInstance(DateTimeInterface $dateTime, ?string $locale = null)
    {
        $date     = $dateTime->format('Y-m-d H:i:s.u');
        $timezone = $dateTime->getTimezone();

        return new static($date, $timezone, $locale);
    }

    /**
     * Takes an instance of DateTime and returns an instance of Time with it's same values.
     *
     * @return static
     *
     * @throws Exception
     *
     * @deprecated         Use createFromInstance() instead
     *
     * @codeCoverageIgnore
     */
    public static function instance(DateTime $dateTime, ?string $locale = null)
    {
        return static::createFromInstance($dateTime, $locale);
    }

    /**
     * Converts the current instance to a mutable DateTime object.
     *
     * @return DateTime
     *
     * @throws Exception
     */
    public function toDateTime()
    {
        return DateTime::createFromFormat(
            'Y-m-d H:i:s.u',
            $this->format('Y-m-d H:i:s.u'),
            $this->getTimezone(),
        );
    }

    // --------------------------------------------------------------------
    // For Testing
    // --------------------------------------------------------------------

    /**
     * Creates an instance of Time that will be returned during testing
     * when calling 'Time::now()' instead of the current time.
     *
     * @param DateTimeInterface|self|string|null $datetime
     * @param DateTimeZone|string|null           $timezone
     *
     * @return void
     *
     * @throws Exception
     */
    public static function setTestNow($datetime = null, $timezone = null, ?string $locale = null)
    {
        // Reset the test instance
        if ($datetime === null) {
            static::$testNow = null;

            return;
        }

        // Convert to a Time instance
        if (is_string($datetime)) {
            $datetime = new static($datetime, $timezone, $locale);
        } elseif ($datetime instanceof DateTimeInterface && ! $datetime instanceof static) {
            $datetime = new static($datetime->format('Y-m-d H:i:s.u'), $timezone);
        }

        static::$testNow = $datetime;
    }

    /**
     * Returns whether we have a testNow instance saved.
     */
    public static function hasTestNow(): bool
    {
        return static::$testNow !== null;
    }

    // --------------------------------------------------------------------
    // Getters
    // --------------------------------------------------------------------

    /**
     * Returns the localized Year
     *
     * @throws Exception
     */
    public function getYear(): string
    {
        return $this->toLocalizedString('y');
    }

    /**
     * Returns the localized Month
     *
     * @throws Exception
     */
    public function getMonth(): string
    {
        return $this->toLocalizedString('M');
    }

    /**
     * Return the localized day of the month.
     *
     * @throws Exception
     */
    public function getDay(): string
    {
        return $this->toLocalizedString('d');
    }

    /**
     * Return the localized hour (in 24-hour format).
     *
     * @throws Exception
     */
    public function getHour(): string
    {
        return $this->toLocalizedString('H');
    }

    /**
     * Return the localized minutes in the hour.
     *
     * @throws Exception
     */
    public function getMinute(): string
    {
        return $this->toLocalizedString('m');
    }

    /**
     * Return the localized seconds
     *
     * @throws Exception
     */
    public function getSecond(): string
    {
        return $this->toLocalizedString('s');
    }

    /**
     * Return the index of the day of the week
     *
     * @throws Exception
     */
    public function getDayOfWeek(): string
    {
        return $this->toLocalizedString('c');
    }

    /**
     * Return the index of the day of the year
     *
     * @throws Exception
     */
    public function getDayOfYear(): string
    {
        return $this->toLocalizedString('D');
    }

    /**
     * Return the index of the week in the month
     *
     * @throws Exception
     */
    public function getWeekOfMonth(): string
    {
        return $this->toLocalizedString('W');
    }

    /**
     * Return the index of the week in the year
     *
     * @throws Exception
     */
    public function getWeekOfYear(): string
    {
        return $this->toLocalizedString('w');
    }

    /**
     * Returns the age in years from the date and 'now'
     *
     * @return int
     *
     * @throws Exception
     */
    public function getAge()
    {
        // future dates have no age
        return max(0, $this->difference(static::now())->getYears());
    }

    /**
     * Returns the number of the current quarter for the year.
     *
     * @throws Exception
     */
    public function getQuarter(): string
    {
        return $this->toLocalizedString('Q');
    }

    /**
     * Are we in daylight savings time currently?
     */
    public function getDst(): bool
    {
        return $this->format('I') === '1'; // 1 if Daylight Saving Time, 0 otherwise.
    }

    /**
     * Returns boolean whether the passed timezone is the same as
     * the local timezone.
     */
    public function getLocal(): bool
    {
        $local = date_default_timezone_get();

        return $local === $this->timezone->getName();
    }

    /**
     * Returns boolean whether object is in UTC.
     */
    public function getUtc(): bool
    {
        return $this->getOffset() === 0;
    }

    /**
     * Returns the name of the current timezone.
     */
    public function getTimezoneName(): string
    {
        return $this->timezone->getName();
    }

    // --------------------------------------------------------------------
    // Setters
    // --------------------------------------------------------------------

    /**
     * Sets the current year for this instance.
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setYear($value)
    {
        return $this->setValue('year', $value);
    }

    /**
     * Sets the month of the year.
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setMonth($value)
    {
        if (is_numeric($value) && ($value < 1 || $value > 12)) {
            throw I18nException::forInvalidMonth((string) $value);
        }

        if (is_string($value) && ! is_numeric($value)) {
            $value = date('m', strtotime("{$value} 1 2017"));
        }

        return $this->setValue('month', $value);
    }

    /**
     * Sets the day of the month.
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setDay($value)
    {
        if ($value < 1 || $value > 31) {
            throw I18nException::forInvalidDay((string) $value);
        }

        $date    = $this->getYear() . '-' . $this->getMonth();
        $lastDay = date('t', strtotime($date));
        if ($value > $lastDay) {
            throw I18nException::forInvalidOverDay($lastDay, (string) $value);
        }

        return $this->setValue('day', $value);
    }

    /**
     * Sets the hour of the day (24 hour cycle)
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setHour($value)
    {
        if ($value < 0 || $value > 23) {
            throw I18nException::forInvalidHour((string) $value);
        }

        return $this->setValue('hour', $value);
    }

    /**
     * Sets the minute of the hour
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setMinute($value)
    {
        if ($value < 0 || $value > 59) {
            throw I18nException::forInvalidMinutes((string) $value);
        }

        return $this->setValue('minute', $value);
    }

    /**
     * Sets the second of the minute.
     *
     * @param int|string $value
     *
     * @return static
     *
     * @throws Exception
     */
    public function setSecond($value)
    {
        if ($value < 0 || $value > 59) {
            throw I18nException::forInvalidSeconds((string) $value);
        }

        return $this->setValue('second', $value);
    }

    /**
     * Helper method to do the heavy lifting of the 'setX' methods.
     *
     * @param int $value
     *
     * @return static
     *
     * @throws Exception
     */
    protected function setValue(string $name, $value)
    {
        [$year, $month, $day, $hour, $minute, $second] = explode('-', $this->format('Y-n-j-G-i-s'));

        ${$name} = $value;

        return static::create(
            (int) $year,
            (int) $month,
            (int) $day,
            (int) $hour,
            (int) $minute,
            (int) $second,
            $this->getTimezoneName(),
            $this->locale,
        );
    }

    /**
     * Returns a new instance with the revised timezone.
     *
     * @param DateTimeZone|string $timezone
     *
     * @throws Exception
     */
    public function setTimezone($timezone): static
    {
        $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone);
        $dateTime = $this->toDateTime()->setTimezone($timezone);

        return static::createFromInstance($dateTime, $this->locale);
    }

    // --------------------------------------------------------------------
    // Add/Subtract
    // --------------------------------------------------------------------

    /**
     * Returns a new Time instance with $seconds added to the time.
     *
     * @return static
     */
    public function addSeconds(int $seconds)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$seconds} seconds"));
    }

    /**
     * Returns a new Time instance with $minutes added to the time.
     *
     * @return static
     */
    public function addMinutes(int $minutes)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$minutes} minutes"));
    }

    /**
     * Returns a new Time instance with $hours added to the time.
     *
     * @return static
     */
    public function addHours(int $hours)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$hours} hours"));
    }

    /**
     * Returns a new Time instance with $days added to the time.
     *
     * @return static
     */
    public function addDays(int $days)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$days} days"));
    }

    /**
     * Returns a new Time instance with $months added to the time.
     *
     * @return static
     */
    public function addMonths(int $months)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$months} months"));
    }

    /**
     * Returns a new Time instance with $months calendar months added to the time.
     */
    public function addCalendarMonths(int $months): static
    {
        $time = clone $this;

        $year  = (int) $time->getYear();
        $month = (int) $time->getMonth();
        $day   = (int) $time->getDay();

        // Adjust total months since year 0
        $totalMonths = ($year * 12 + $month - 1) + $months;

        // Recalculate year and month
        $newYear  = intdiv($totalMonths, 12);
        $newMonth = $totalMonths % 12 + 1;

        // Get last day of new month
        $lastDayOfMonth = cal_days_in_month(CAL_GREGORIAN, $newMonth, $newYear);
        $correctedDay   = min($day, $lastDayOfMonth);

        return static::create($newYear, $newMonth, $correctedDay, (int) $this->getHour(), (int) $this->getMinute(), (int) $this->getSecond(), $this->getTimezone(), $this->locale);
    }

    /**
     * Returns a new Time instance with $months calendar months subtracted from the time
     */
    public function subCalendarMonths(int $months): static
    {
        return $this->addCalendarMonths(-$months);
    }

    /**
     * Returns a new Time instance with $years added to the time.
     *
     * @return static
     */
    public function addYears(int $years)
    {
        $time = clone $this;

        return $time->add(DateInterval::createFromDateString("{$years} years"));
    }

    /**
     * Returns a new Time instance with $seconds subtracted from the time.
     *
     * @return static
     */
    public function subSeconds(int $seconds)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$seconds} seconds"));
    }

    /**
     * Returns a new Time instance with $minutes subtracted from the time.
     *
     * @return static
     */
    public function subMinutes(int $minutes)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$minutes} minutes"));
    }

    /**
     * Returns a new Time instance with $hours subtracted from the time.
     *
     * @return static
     */
    public function subHours(int $hours)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$hours} hours"));
    }

    /**
     * Returns a new Time instance with $days subtracted from the time.
     *
     * @return static
     */
    public function subDays(int $days)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$days} days"));
    }

    /**
     * Returns a new Time instance with $months subtracted from the time.
     *
     * @return static
     */
    public function subMonths(int $months)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$months} months"));
    }

    /**
     * Returns a new Time instance with $hours subtracted from the time.
     *
     * @return static
     */
    public function subYears(int $years)
    {
        $time = clone $this;

        return $time->sub(DateInterval::createFromDateString("{$years} years"));
    }

    // --------------------------------------------------------------------
    // Formatters
    // --------------------------------------------------------------------

    /**
     * Returns the localized value of the date in the format 'Y-m-d H:i:s'
     *
     * @return false|string
     *
     * @throws Exception
     */
    public function toDateTimeString()
    {
        return $this->toLocalizedString('yyyy-MM-dd HH:mm:ss');
    }

    /**
     * Returns a localized version of the date in Y-m-d format.
     *
     * @return string
     *
     * @throws Exception
     */
    public function toDateString()
    {
        return $this->toLocalizedString('yyyy-MM-dd');
    }

    /**
     * Returns a localized version of the date in nicer date format:
     *
     *  i.e. Apr 1, 2017
     *
     * @return string
     *
     * @throws Exception
     */
    public function toFormattedDateString()
    {
        return $this->toLocalizedString('MMM d, yyyy');
    }

    /**
     * Returns a localized version of the time in nicer date format:
     *
     *  i.e. 13:20:33
     *
     * @return string
     *
     * @throws Exception
     */
    public function toTimeString()
    {
        return $this->toLocalizedString('HH:mm:ss');
    }

    /**
     * Returns the localized value of this instance in $format.
     *
     * @return false|string
     *
     * @throws Exception
     */
    public function toLocalizedString(?string $format = null)
    {
        $format ??= $this->toStringFormat;

        return IntlDateFormatter::formatObject($this->toDateTime(), $format, $this->locale);
    }

    // --------------------------------------------------------------------
    // Comparison
    // --------------------------------------------------------------------

    /**
     * Determines if the datetime passed in is equal to the current instance.
     * Equal in this case means that they represent the same moment in time,
     * and are not required to be in the same timezone, as both times are
     * converted to UTC and compared that way.
     *
     * @param DateTimeInterface|self|string $testTime
     *
     * @throws Exception
     */
    public function equals($testTime, ?string $timezone = null): bool
    {
        $testTime = $this->getUTCObject($testTime, $timezone);

        $ourTime = $this->toDateTime()
            ->setTimezone(new DateTimeZone('UTC'))
            ->format('Y-m-d H:i:s.u');

        return $testTime->format('Y-m-d H:i:s.u') === $ourTime;
    }

    /**
     * Ensures that the times are identical, taking timezone into account.
     *
     * @param DateTimeInterface|self|string $testTime
     *
     * @throws Exception
     */
    public function sameAs($testTime, ?string $timezone = null): bool
    {
        if ($testTime instanceof DateTimeInterface) {
            $testTime = $testTime->format('Y-m-d H:i:s.u O');
        } elseif (is_string($testTime)) {
            $timezone = in_array($timezone, [null, '', '0'], true) ? $this->timezone : $timezone;
            $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone);
            $testTime = new DateTime($testTime, $timezone);
            $testTime = $testTime->format('Y-m-d H:i:s.u O');
        }

        $ourTime = $this->format('Y-m-d H:i:s.u O');

        return $testTime === $ourTime;
    }

    /**
     * Determines if the current instance's time is before $testTime,
     * after converting to UTC.
     *
     * @param DateTimeInterface|self|string $testTime
     *
     * @throws Exception
     */
    public function isBefore($testTime, ?string $timezone = null): bool
    {
        $testTime = $this->getUTCObject($testTime, $timezone);

        $testTimestamp = $testTime->getTimestamp();
        $ourTimestamp  = $this->getTimestamp();

        if ($ourTimestamp === $testTimestamp) {
            return $this->format('u') < $testTime->format('u');
        }

        return $ourTimestamp < $testTimestamp;
    }

    /**
     * Determines if the current instance's time is after $testTime,
     * after converting in UTC.
     *
     * @param DateTimeInterface|self|string $testTime
     *
     * @throws Exception
     */
    public function isAfter($testTime, ?string $timezone = null): bool
    {
        $testTime = $this->getUTCObject($testTime, $timezone);

        $testTimestamp = $testTime->getTimestamp();
        $ourTimestamp  = $this->getTimestamp();

        if ($ourTimestamp === $testTimestamp) {
            return $this->format('u') > $testTime->format('u');
        }

        return $ourTimestamp > $testTimestamp;
    }

    /**
     * Determines if the current instance's time is in the past.
     *
     * @throws Exception
     */
    public function isPast(): bool
    {
        return $this->isBefore(static::now($this->timezone));
    }

    /**
     * Determines if the current instance's time is in the future.
     *
     * @throws Exception
     */
    public function isFuture(): bool
    {
        return $this->isAfter(static::now($this->timezone));
    }

    // --------------------------------------------------------------------
    // Differences
    // --------------------------------------------------------------------

    /**
     * Returns a text string that is easily readable that describes
     * how long ago, or how long from now, a date is, like:
     *
     *  - 3 weeks ago
     *  - in 4 days
     *  - 6 hours ago
     *
     * @return string
     *
     * @throws Exception
     */
    public function humanize()
    {
        $now  = IntlCalendar::fromDateTime(self::now($this->timezone)->toDateTime());
        $time = $this->getCalendar()->getTime();

        $years   = $now->fieldDifference($time, IntlCalendar::FIELD_YEAR);
        $months  = $now->fieldDifference($time, IntlCalendar::FIELD_MONTH);
        $days    = $now->fieldDifference($time, IntlCalendar::FIELD_DAY_OF_YEAR);
        $hours   = $now->fieldDifference($time, IntlCalendar::FIELD_HOUR_OF_DAY);
        $minutes = $now->fieldDifference($time, IntlCalendar::FIELD_MINUTE);

        $phrase = null;

        if ($years !== 0) {
            $phrase = lang('Time.years', [abs($years)]);
            $before = $years < 0;
        } elseif ($months !== 0) {
            $phrase = lang('Time.months', [abs($months)]);
            $before = $months < 0;
        } elseif ($days !== 0 && (abs($days) >= 7)) {
            $weeks  = ceil($days / 7);
            $phrase = lang('Time.weeks', [abs($weeks)]);
            $before = $days < 0;
        } elseif ($days !== 0) {
            $before = $days < 0;

            // Yesterday/Tomorrow special cases
            if (abs($days) === 1) {
                return $before ? lang('Time.yesterday') : lang('Time.tomorrow');
            }

            $phrase = lang('Time.days', [abs($days)]);
        } elseif ($hours !== 0) {
            $phrase = lang('Time.hours', [abs($hours)]);
            $before = $hours < 0;
        } elseif ($minutes !== 0) {
            $phrase = lang('Time.minutes', [abs($minutes)]);
            $before = $minutes < 0;
        } else {
            return lang('Time.now');
        }

        return $before ? lang('Time.ago', [$phrase]) : lang('Time.inFuture', [$phrase]);
    }

    /**
     * @param DateTimeInterface|self|string $testTime
     *
     * @return TimeDifference
     *
     * @throws Exception
     */
    public function difference($testTime, ?string $timezone = null)
    {
        if (is_string($testTime)) {
            $timezone = ($timezone !== null) ? new DateTimeZone($timezone) : $this->timezone;
            $testTime = new DateTime($testTime, $timezone);
        } elseif ($testTime instanceof static) {
            $testTime = $testTime->toDateTime();
        }

        assert($testTime instanceof DateTime);

        if ($this->timezone->getOffset($this) !== $testTime->getTimezone()->getOffset($this)) {
            $testTime = $this->getUTCObject($testTime, $timezone);
            $ourTime  = $this->getUTCObject($this);
        } else {
            $ourTime = $this->toDateTime();
        }

        return new TimeDifference($ourTime, $testTime);
    }

    // --------------------------------------------------------------------
    // Utilities
    // --------------------------------------------------------------------

    /**
     * Returns a Time instance with the timezone converted to UTC.
     *
     * @param DateTimeInterface|self|string $time
     *
     * @return DateTime|static
     *
     * @throws Exception
     */
    public function getUTCObject($time, ?string $timezone = null)
    {
        if ($time instanceof static) {
            $time = $time->toDateTime();
        } elseif (is_string($time)) {
            $timezone = in_array($timezone, [null, '', '0'], true) ? $this->timezone : $timezone;
            $timezone = $timezone instanceof DateTimeZone ? $timezone : new DateTimeZone($timezone);
            $time     = new DateTime($time, $timezone);
        }

        if ($time instanceof DateTime || $time instanceof DateTimeImmutable) {
            $time = $time->setTimezone(new DateTimeZone('UTC'));
        }

        return $time;
    }

    /**
     * Returns the IntlCalendar object used for this object,
     * taking into account the locale, date, etc.
     *
     * Primarily used internally to provide the difference and comparison functions,
     * but available for public consumption if they need it.
     *
     * @return IntlCalendar
     *
     * @throws Exception
     */
    public function getCalendar()
    {
        return IntlCalendar::fromDateTime($this->toDateTime());
    }

    /**
     * Check a time string to see if it includes a relative date (like 'next Tuesday').
     */
    protected static function hasRelativeKeywords(string $time): bool
    {
        // skip common format with a '-' in it
        if (preg_match('/\d{4}-\d{1,2}-\d{1,2}/', $time) !== 1) {
            return preg_match(static::$relativePattern, $time) > 0;
        }

        return false;
    }

    /**
     * Outputs a short format version of the datetime.
     * The output is NOT localized intentionally.
     */
    public function __toString(): string
    {
        return $this->format('Y-m-d H:i:s');
    }

    /**
     * Allow for property-type access to any getX method...
     *
     * Note that we cannot use this for any of our setX methods,
     * as they return new Time objects, but the __set ignores
     * return values.
     * See http://php.net/manual/en/language.oop5.overloading.php
     *
     * @param string $name
     *
     * @return array<int|string, mixed>|bool|DateTimeInterface|DateTimeZone|int|IntlCalendar|self|string|null
     */
    public function __get($name)
    {
        $method = 'get' . ucfirst($name);

        if (method_exists($this, $method)) {
            return $this->{$method}();
        }

        return null;
    }

    /**
     * Allow for property-type checking to any getX method...
     *
     * @param string $name
     */
    public function __isset($name): bool
    {
        $method = 'get' . ucfirst($name);

        return method_exists($this, $method);
    }

    /**
     * This is called when we unserialize the Time object.
     *
     * @param array{date: string, timezone: string, timezone_type: int} $data
     */
    public function __unserialize(array $data): void
    {
        parent::__construct($data['date'], new DateTimeZone($data['timezone']));
    }
}
