<?php
declare(strict_types=1);

/**
 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @license       https://www.opensource.org/licenses/mit-license.php MIT License
 */

namespace Cake\Chronos;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;
use Stringable;

/**
 * @phpstan-consistent-constructor
 */
class ChronosTime implements Stringable
{
    /**
     * @var int
     */
    protected const TICKS_PER_MICROSECOND = 1;

    /**
     * @var int
     */
    protected const TICKS_PER_SECOND = 1000000;

    /**
     * @var int
     */
    protected const TICKS_PER_MINUTE = self::TICKS_PER_SECOND * 60;

    /**
     * @var int
     */
    protected const TICKS_PER_HOUR = self::TICKS_PER_MINUTE * 60;

    /**
     * @var int
     */
    protected const TICKS_PER_DAY = self::TICKS_PER_HOUR * 24;

    /**
     * Default format to use for __toString method.
     *
     * @var string
     */
    public const DEFAULT_TO_STRING_FORMAT = 'H:i:s';

    /**
     * Format to use for __toString method.
     */
    protected static string $toStringFormat = self::DEFAULT_TO_STRING_FORMAT;

    protected int $ticks;

    /**
     * Copies time from onther instance or from time string in the format HH[:.]mm or HH[:.]mm[:.]ss.u.
     *
     * Defaults to server time.
     *
     * @param \Cake\Chronos\ChronosTime|\DateTimeInterface|string|null $time Time
     * @param \DateTimeZone|string|null $timezone The timezone to use for now
     */
    public function __construct(
        ChronosTime|DateTimeInterface|string|null $time = null,
        DateTimeZone|string|null $timezone = null,
    ) {
        if ($time === null) {
            $time = Chronos::getTestNow() ?? Chronos::now();
            if ($timezone !== null) {
                $time = $time->setTimezone($timezone);
            }
            $this->ticks = static::parseString($time->format('H:i:s.u'));
        } elseif (is_string($time)) {
            $this->ticks = static::parseString($time);
        } elseif ($time instanceof ChronosTime) {
            $this->ticks = $time->ticks;
        } else {
            $this->ticks = static::parseString($time->format('H:i:s.u'));
        }
    }

    /**
     * Copies time from onther instance or from string in the format HH[:.]mm or HH[:.]mm[:.]ss.u
     *
     * Defaults to server time.
     *
     * @param \Cake\Chronos\ChronosTime|\DateTimeInterface|string $time Time
     * @param \DateTimeZone|string|null $timezone The timezone to use for now
     * @return static
     */
    public static function parse(
        ChronosTime|DateTimeInterface|string|null $time = null,
        DateTimeZone|string|null $timezone = null,
    ): static {
        return new static($time, $timezone);
    }

    /**
     * @param string $time Time string in the format HH[:.]mm or HH[:.]mm[:.]ss.u
     * @return int
     */
    protected static function parseString(string $time): int
    {
        if (!preg_match('/^\s*(\d{1,2})[:.](\d{1,2})(?|[:.](\d{1,2})[.](\d+)|[:.](\d{1,2}))?\s*$/', $time, $matches)) {
            throw new InvalidArgumentException(
                sprintf('Time string `%s` is not in expected format `HH[:.]mm` or `HH[:.]mm[:.]ss.u`.', $time),
            );
        }

        $hours = (int)$matches[1];
        $minutes = (int)$matches[2];
        $seconds = (int)($matches[3] ?? 0);
        $microseconds = (int)substr($matches[4] ?? '', 0, 6);

        if ($hours > 24 || $minutes > 59 || $seconds > 59 || $microseconds > 999_999) {
            throw new InvalidArgumentException(sprintf('Time string `%s` contains invalid values.', $time));
        }

        $ticks = $hours * self::TICKS_PER_HOUR;
        $ticks += $minutes * self::TICKS_PER_MINUTE;
        $ticks += $seconds * self::TICKS_PER_SECOND;
        $ticks += $microseconds * self::TICKS_PER_MICROSECOND;

        return $ticks % self::TICKS_PER_DAY;
    }

    /**
     * Returns instance set to server time.
     *
     * @param \DateTimeZone|string|null $timezone The timezone to use for now
     * @return static
     */
    public static function now(DateTimeZone|string|null $timezone = null): static
    {
        return new static(null, $timezone);
    }

    /**
     * Returns instance set to midnight.
     *
     * @return static
     */
    public static function midnight(): static
    {
        return new static('00:00:00');
    }

    /**
     * Returns instance set to noon.
     *
     * @return static
     */
    public static function noon(): static
    {
        return new static('12:00:00');
    }

    /**
     * Returns instance set to end of day - either
     * 23:59:59 or 23:59:59.999999 if `$microseconds` is true
     *
     * @param bool $microseconds Whether to set microseconds or not
     * @return static
     */
    public static function endOfDay(bool $microseconds = false): static
    {
        if ($microseconds) {
            return new static('23:59:59.999999');
        }

        return new static('23:59:59');
    }

    /**
     * Returns clock microseconds.
     *
     * @return int
     */
    public function getMicroseconds(): int
    {
        return intdiv($this->ticks % self::TICKS_PER_SECOND, self::TICKS_PER_MICROSECOND);
    }

    /**
     * Sets clock microseconds.
     *
     * @param int $microseconds Clock microseconds
     * @return static
     */
    public function setMicroseconds(int $microseconds): static
    {
        $baseTicks = $this->ticks - $this->ticks % self::TICKS_PER_SECOND;
        $newTicks = static::mod($baseTicks + $microseconds * self::TICKS_PER_MICROSECOND, self::TICKS_PER_DAY);

        $clone = clone $this;
        $clone->ticks = $newTicks;

        return $clone;
    }

    /**
     * Return clock seconds.
     *
     * @return int
     */
    public function getSeconds(): int
    {
        $secondsTicks = $this->ticks % self::TICKS_PER_MINUTE - $this->ticks % self::TICKS_PER_SECOND;

        return intdiv($secondsTicks, self::TICKS_PER_SECOND);
    }

    /**
     * Set clock seconds.
     *
     * @param int $seconds Clock seconds
     * @return static
     */
    public function setSeconds(int $seconds): static
    {
        $baseTicks = $this->ticks - ($this->ticks % self::TICKS_PER_MINUTE - $this->ticks % self::TICKS_PER_SECOND);
        $newTicks = static::mod($baseTicks + $seconds * self::TICKS_PER_SECOND, self::TICKS_PER_DAY);

        $clone = clone $this;
        $clone->ticks = $newTicks;

        return $clone;
    }

    /**
     * Returns clock minutes.
     *
     * @return int
     */
    public function getMinutes(): int
    {
        $minutesTicks = $this->ticks % self::TICKS_PER_HOUR - $this->ticks % self::TICKS_PER_MINUTE;

        return intdiv($minutesTicks, self::TICKS_PER_MINUTE);
    }

    /**
     * Set clock minutes.
     *
     * @param int $minutes Clock minutes
     * @return static
     */
    public function setMinutes(int $minutes): static
    {
        $baseTicks = $this->ticks - ($this->ticks % self::TICKS_PER_HOUR - $this->ticks % self::TICKS_PER_MINUTE);
        $newTicks = static::mod($baseTicks + $minutes * self::TICKS_PER_MINUTE, self::TICKS_PER_DAY);

        $clone = clone $this;
        $clone->ticks = $newTicks;

        return $clone;
    }

    /**
     * Returns clock hours.
     *
     * @return int
     */
    public function getHours(): int
    {
        $hoursInTicks = $this->ticks - $this->ticks % self::TICKS_PER_HOUR;

        return intdiv($hoursInTicks, self::TICKS_PER_HOUR);
    }

    /**
     * Set clock hours.
     *
     * @param int $hours Clock hours
     * @return static
     */
    public function setHours(int $hours): static
    {
        $baseTicks = $this->ticks - ($this->ticks - $this->ticks % self::TICKS_PER_HOUR);
        $newTicks = static::mod($baseTicks + $hours * self::TICKS_PER_HOUR, self::TICKS_PER_DAY);

        $clone = clone $this;
        $clone->ticks = $newTicks;

        return $clone;
    }

    /**
     * Sets clock time.
     *
     * @param int $hours Clock hours
     * @param int $minutes Clock minutes
     * @param int $seconds Clock seconds
     * @param int $microseconds Clock microseconds
     * @return static
     */
    public function setTime(int $hours = 0, int $minutes = 0, int $seconds = 0, int $microseconds = 0): static
    {
        $ticks = $hours * self::TICKS_PER_HOUR +
            $minutes * self::TICKS_PER_MINUTE +
            $seconds * self::TICKS_PER_SECOND +
            $microseconds * self::TICKS_PER_MICROSECOND;
        $ticks = static::mod($ticks, self::TICKS_PER_DAY);

        $clone = clone $this;
        $clone->ticks = $ticks;

        return $clone;
    }

    /**
     * Resets time to the start of the current hour.
     *
     * @return static
     */
    public function startOfHour(): static
    {
        $hourTicks = $this->ticks - $this->ticks % self::TICKS_PER_HOUR;

        $clone = clone $this;
        $clone->ticks = $hourTicks;

        return $clone;
    }

    /**
     * Sets time to the end of the current hour.
     *
     * @return static
     */
    public function endOfHour(): static
    {
        $hourTicks = $this->ticks - $this->ticks % self::TICKS_PER_HOUR;
        $endTicks = $hourTicks + self::TICKS_PER_HOUR - 1;

        $clone = clone $this;
        $clone->ticks = $endTicks;

        return $clone;
    }

    /**
     * @param int $a Left side
     * @param int $a Right side
     * @return int
     */
    protected static function mod(int $a, int $b): int
    {
        if ($a < 0) {
            return $a % $b + $b;
        }

        return $a % $b;
    }

    /**
     * Formats string using the same syntax as `DateTimeImmutable::format()`.
     *
     * As this uses DateTimeImmutable::format() to format the string, non-time formatters
     * will still be interpreted. Be sure to escape those characters first.
     *
     * @param string $format Format string
     * @return string
     */
    public function format(string $format): string
    {
        return $this->toDateTimeImmutable()->format($format);
    }

    /**
     * Reset the format used to the default when converting to a string
     *
     * @return void
     */
    public static function resetToStringFormat(): void
    {
        static::setToStringFormat(static::DEFAULT_TO_STRING_FORMAT);
    }

    /**
     * Set the default format used when converting to a string
     *
     * @param string $format The format to use in future __toString() calls.
     * @return void
     */
    public static function setToStringFormat(string $format): void
    {
        static::$toStringFormat = $format;
    }

    /**
     * Format the instance as a string using the set format
     *
     * @return string
     */
    public function __toString(): string
    {
        return $this->format(static::$toStringFormat);
    }

    /**
     * Returns whether time is equal to target time.
     *
     * @param \Cake\Chronos\ChronosTime $target Target time
     * @return bool
     */
    public function equals(ChronosTime $target): bool
    {
        return $this->ticks === $target->ticks;
    }

    /**
     * Returns whether time is greater than target time.
     *
     * @param \Cake\Chronos\ChronosTime $target Target time
     * @return bool
     */
    public function greaterThan(ChronosTime $target): bool
    {
        return $this->ticks > $target->ticks;
    }

    /**
     * Returns whether time is greater than or equal to target time.
     *
     * @param \Cake\Chronos\ChronosTime $target Target time
     * @return bool
     */
    public function greaterThanOrEquals(ChronosTime $target): bool
    {
        return $this->ticks >= $target->ticks;
    }

    /**
     * Returns whether time is less than target time.
     *
     * @param \Cake\Chronos\ChronosTime $target Target time
     * @return bool
     */
    public function lessThan(ChronosTime $target): bool
    {
        return $this->ticks < $target->ticks;
    }

    /**
     * Returns whether time is less than or equal to target time.
     *
     * @param \Cake\Chronos\ChronosTime $target Target time
     * @return bool
     */
    public function lessThanOrEquals(ChronosTime $target): bool
    {
        return $this->ticks <= $target->ticks;
    }

    /**
     * Returns whether time is between time range.
     *
     * @param \Cake\Chronos\ChronosTime $start Start of target range
     * @param \Cake\Chronos\ChronosTime $end End of target range
     * @param bool $equals Whether to include the beginning and end of range
     * @return bool
     */
    public function between(ChronosTime $start, ChronosTime $end, bool $equals = true): bool
    {
        if ($start->greaterThan($end)) {
            [$start, $end] = [$end, $start];
        }

        if ($equals) {
            return $this->greaterThanOrEquals($start) && $this->lessThanOrEquals($end);
        }

        return $this->greaterThan($start) && $this->lessThan($end);
    }

    /**
     * Returns whether time is start of day.
     *
     * @return bool
     */
    public function isStartOfDay(): bool
    {
        return $this->ticks === 0;
    }

    /**
     * Returns whether time is end of day.
     *
     * Compares against 23:59:59, ignoring microseconds.
     *
     * @return bool
     */
    public function isEndOfDay(): bool
    {
        $endOfDayTicks = 23 * self::TICKS_PER_HOUR
            + 59 * self::TICKS_PER_MINUTE
            + 59 * self::TICKS_PER_SECOND;

        $ticksWithoutMicroseconds = $this->ticks - $this->ticks % self::TICKS_PER_SECOND;

        return $ticksWithoutMicroseconds === $endOfDayTicks;
    }

    /**
     * Returns whether time is midnight.
     *
     * @return bool
     */
    public function isMidnight(): bool
    {
        return $this->isStartOfDay();
    }

    /**
     * Returns whether time is midday.
     *
     * @return bool
     */
    public function isMidday(): bool
    {
        return $this->ticks === 12 * self::TICKS_PER_HOUR;
    }

    /**
     * Returns an `DateTimeImmutable` instance set to this clock time.
     *
     * @param \DateTimeZone|string|null $timezone Time zone the DateTimeImmutable instance will be in
     * @return \DateTimeImmutable
     */
    public function toDateTimeImmutable(DateTimeZone|string|null $timezone = null): DateTimeImmutable
    {
        $timezone = is_string($timezone) ? new DateTimeZone($timezone) : $timezone;

        return (new DateTimeImmutable(timezone: $timezone))->setTime(
            $this->getHours(),
            $this->getMinutes(),
            $this->getSeconds(),
            $this->getMicroseconds(),
        );
    }

    /**
     * Returns an `DateTimeImmutable` instance set to this clock time.
     *
     * Alias of `toDateTimeImmutable()`.
     *
     * @param \DateTimeZone|string|null $timezone Time zone the DateTimeImmutable instance will be in
     * @return \DateTimeImmutable
     */
    public function toNative(DateTimeZone|string|null $timezone = null): DateTimeImmutable
    {
        return $this->toDateTimeImmutable($timezone);
    }

    /**
     * Returns the time as an associative array.
     *
     * @return array{hour: int, minute: int, second: int, microsecond: int}
     */
    public function toArray(): array
    {
        return [
            'hour' => $this->getHours(),
            'minute' => $this->getMinutes(),
            'second' => $this->getSeconds(),
            'microsecond' => $this->getMicroseconds(),
        ];
    }
}
