<?php
/**
 * @link https://craftcms.com/
 * @copyright Copyright (c) Pixel & Tonic, Inc.
 * @license https://craftcms.github.io/license/
 */

namespace craft\helpers;

use Craft;
use craft\i18n\Locale;
use DateInterval;
use DateTime;
use DateTimeImmutable;
use DateTimeZone;

/**
 * Class DateTimeHelper
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since 3.0
 */
class DateTimeHelper
{
    // Constants
    // =========================================================================

    /**
     * Number of seconds in a minute.
     *
     * @var int
     */
    const SECONDS_MINUTE = 60;

    /**
     * Number of seconds in an hour.
     *
     * @var int
     */
    const SECONDS_HOUR = 3600;

    /**
     * Number of seconds in a day.
     *
     * @var int
     */
    const SECONDS_DAY = 86400;

    /**
     * The number of seconds in a month.
     *
     * Based on a 30.4368 day month, with the product rounded.
     *
     * @var int
     */
    const SECONDS_MONTH = 2629740;

    /**
     * The number of seconds in a year.
     *
     * Based on a 365.2416 day year, with the product rounded.
     *
     * @var int
     */
    const SECONDS_YEAR = 31556874;

    // Properties
    // =========================================================================

    /**
     * @var array Translation pairs for [[translateDate()]]
     */
    private static $_translationPairs;

    // Public Methods
    // =========================================================================

    /**
     * Converts a value into a DateTime object.
     *
     * `$value` can be in the following formats:
     *
     *  - All W3C date and time formats (http://www.w3.org/TR/NOTE-datetime)
     *  - MySQL DATE and DATETIME formats (http://dev.mysql.com/doc/refman/5.1/en/datetime.html)
     *  - Relaxed versions of W3C and MySQL formats (single-digit months, days, and hours)
     *  - Unix timestamps
     *  - An array with at least one of these keys defined: `datetime`, `date`, or `time`. Supported keys include:
     *      - `date` – a date string in `YYYY-MM-DD` format or the current locale’s short date format
     *      - `time` – a time string in `HH:MM` (24-hour) format or the current locale’s short time format
     *      - `datetime` – A timestamp in any of the non-array formats supported by this method
     *      - `timezone` – A [valid PHP timezone](http://php.net/manual/en/timezones.php). If set, this will override
     *        the assumed timezone per `$assumeSystemTimeZone`.
     *
     * @param string|int|array|null $value The value that should be converted to a DateTime object.
     * @param bool $assumeSystemTimeZone Whether it should be assumed that the value was set in the system timezone if
     * the timezone was not specified. If this is `false`, UTC will be assumed.
     * @param bool $setToSystemTimeZone Whether to set the resulting DateTime object to the system timezone.
     * @return DateTime|false The DateTime object, or `false` if $object could not be converted to one
     * @throws \Exception
     */
    public static function toDateTime($value, bool $assumeSystemTimeZone = false, bool $setToSystemTimeZone = true)
    {
        if ($value instanceof DateTime) {
            return $value;
        }

        if (!$value) {
            return false;
        }

        $defaultTimeZone = ($assumeSystemTimeZone ? Craft::$app->getTimeZone() : 'UTC');

        if (is_array($value)) {
            if (empty($value['datetime']) && empty($value['date']) && empty($value['time'])) {
                return false;
            }

            // Did they specify a timezone?
            if (!empty($value['timezone']) && ($normalizedTimeZone = static::normalizeTimeZone($value['timezone'])) !== false) {
                $timeZone = $normalizedTimeZone;
            } else {
                $timeZone = $defaultTimeZone;
            }

            // Did they specify a full timestamp ?
            if (!empty($value['datetime'])) {
                list($date, $format) = self::_parseDateTime($value['datetime'], $timeZone);
                if ($format === false) {
                    return false;
                }
            } else {
                // Did they specify a date?
                if (!empty($value['date'])) {
                    list($date, $format) = self::_parseDate($value['date']);
                } else {
                    // Default to the current date
                    $format = 'Y-m-d';
                    $date = (new DateTime('now', new DateTimeZone($timeZone)))->format($format);
                }

                // Did they specify a time?
                if (!empty($value['time'])) {
                    list($time, $timeFormat) = self::_parseTime($value['time']);
                    $format .= ' ' . $timeFormat;
                    $date .= ' ' . $time;
                }

                // Add the timezone
                $format .= ' e';
                $date .= ' ' . $timeZone;
            }
        } else {
            list($date, $format) = self::_parseDateTime($value, $defaultTimeZone);
            if ($format === false) {
                return false;
            }
        }

        $dt = DateTime::createFromFormat('!' . $format, $date);

        if ($dt !== false && $setToSystemTimeZone) {
            $dt->setTimezone(new DateTimeZone(Craft::$app->getTimeZone()));
        }

        return $dt;
    }

    /**
     * Normalizes a timezone string to a PHP timezone identifier.
     *
     * Supports the following formats:
     *  - Time zone abbreviation (EST, MDT)
     *  - Difference to Greenwich time (GMT) in hours, with/without a colon between the hours and minutes (+0200, -0200, +02:00, -02:00)
     *  - A PHP timezone identifier (UTC, GMT, Atlantic/Azores)
     *
     * @param string $timeZone The timezone to be normalized
     * @return string|false The PHP timezone identifier, or `false` if it could not be determined
     */
    public static function normalizeTimeZone(string $timeZone)
    {
        // Is it already a PHP timezone identifier?
        if (in_array($timeZone, timezone_identifiers_list(), true)) {
            return $timeZone;
        }

        // Is this a timezone abbreviation?
        if (($timeZoneName = timezone_name_from_abbr($timeZone)) !== false) {
            return $timeZoneName;
        }

        // Is it the difference to GMT?
        if (preg_match('/[+\-]\d\d\:?\d\d/', $timeZone, $matches)) {
            $format = strpos($timeZone, ':') !== false ? 'e' : 'O';
            $dt = DateTime::createFromFormat($format, $timeZone, new DateTimeZone('UTC'));

            if ($dt !== false) {
                return $dt->format('e');
            }
        }

        // Dunno
        return false;
    }

    /**
     * Returns the timezone abbreviation for a given timezone name.
     *
     * @param string $timeZone
     * @return string
     */
    public static function timeZoneAbbreviation(string $timeZone): string
    {
        return (new DateTime())
            ->setTimezone(new DateTimeZone($timeZone))
            ->format('T');
    }

    /**
     * Returns a given timezone’s offset from UTC (e.g. '+10:00' or '-06:00').
     *
     * @param string $timeZone
     * @return string
     */
    public static function timeZoneOffset(string $timeZone): string
    {
        $offset = (new DateTimeZone($timeZone))
            ->getOffset(new DateTime('now', new DateTimeZone('UTC')));

        // Adapted from http://stackoverflow.com/a/13822928/1688568
        return sprintf('%s%02d:%02d',
            $offset < 0 ? '-' : '+',
            abs($offset) / 3600,
            abs($offset) / 60 % 60);
    }

    /**
     * Determines whether the given value is an ISO-8601-formatted date, as formatted by either
     * [DateTime::ATOM](http://php.net/manual/en/class.datetime.php#datetime.constants.atom) or
     * [DateTime::ISO8601](http://php.net/manual/en/class.datetime.php#datetime.constants.iso8601) (with or without
     * the colon between the hours and minutes of the timezone).
     *
     * @param mixed $value The timestamp to check
     * @return bool Whether the value is an ISO-8601 date string
     */
    public static function isIso8601($value): bool
    {
        return is_string($value) && preg_match('/^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[\+\-]\d\d\:?\d\d$/', $value);
    }

    /**
     * Converts a date to an ISO-8601 string.
     *
     * @param mixed $date The date, in any format that [[toDateTime()]] supports.
     * @return string|false The date formatted as an ISO-8601 string, or `false` if $date was not a valid date
     */
    public static function toIso8601($date)
    {
        $date = static::toDateTime($date);

        if ($date !== false) {
            return $date->format(DateTime::ATOM);
        }

        return false;
    }

    /**
     * @return DateTime
     */
    public static function currentUTCDateTime(): DateTime
    {
        return new DateTime(null, new DateTimeZone('UTC'));
    }

    /**
     * @return int
     */
    public static function currentTimeStamp(): int
    {
        $date = static::currentUTCDateTime();

        return $date->getTimestamp();
    }

    /**
     * Translates the words in a formatted date string to the application’s language.
     *
     * @param string $str The formatted date string
     * @param string|null $language The language code (e.g. `en-US`, `en`). If this is null, the current
     * [[\yii\base\Application::language|application language]] will be used.
     * @return string The translated date string
     * @deprecated in 3.0.6. Use [[craft\i18n\Formatter::asDate()]] instead.
     */
    public static function translateDate(string $str, string $language = null): string
    {
        Craft::$app->getDeprecator()->log(__METHOD__, __METHOD__ . ' is deprecated. Use craft\i18n\Formatter::asDate() instead.');

        if ($language === null) {
            $language = Craft::$app->language;
        }

        if (strpos($language, 'en') === 0) {
            return $str;
        }

        $translations = self::_getDateTranslations($language);

        return strtr($str, $translations);
    }

    /**
     * @param int $seconds The number of seconds
     * @param bool $showSeconds Whether to output seconds or not
     * @return string
     */
    public static function secondsToHumanTimeDuration(int $seconds, bool $showSeconds = true): string
    {
        $secondsInWeek = 604800;
        $secondsInDay = 86400;
        $secondsInHour = 3600;
        $secondsInMinute = 60;

        $weeks = floor($seconds / $secondsInWeek);
        $seconds %= $secondsInWeek;

        $days = floor($seconds / $secondsInDay);
        $seconds %= $secondsInDay;

        $hours = floor($seconds / $secondsInHour);
        $seconds %= $secondsInHour;

        if ($showSeconds) {
            $minutes = floor($seconds / $secondsInMinute);
            $seconds %= $secondsInMinute;
        } else {
            $minutes = round($seconds / $secondsInMinute);
            $seconds = 0;
        }

        $timeComponents = [];

        if ($weeks) {
            $timeComponents[] = $weeks == 1 ? Craft::t('app', '1 week') : Craft::t('app', '{num} weeks', ['num' => $weeks]);
        }

        if ($days) {
            $timeComponents[] = $days == 1 ? Craft::t('app', '1 day') : Craft::t('app', '{num} days', ['num' => $days]);
        }

        if ($hours) {
            $timeComponents[] = $hours == 1 ? Craft::t('app', '1 hour') : Craft::t('app', '{num} hours', ['num' => $hours]);
        }

        if ($minutes || (!$showSeconds && !$weeks && !$days && !$hours)) {
            $timeComponents[] = $minutes == 1 ? Craft::t('app', '1 minute') : Craft::t('app', '{num} minutes', ['num' => $minutes]);
        }

        if ($seconds || ($showSeconds && !$weeks && !$days && !$hours && !$minutes)) {
            $timeComponents[] = $seconds == 1 ? Craft::t('app', '1 second') : Craft::t('app', '{num} seconds', ['num' => $seconds]);
        }

        return implode(', ', $timeComponents);
    }

    /**
     * @param string|int $timestamp
     * @return bool
     */
    public static function isValidTimeStamp($timestamp): bool
    {
        if (!is_numeric($timestamp)) {
            return false;
        }

        $timestamp = (int)$timestamp;

        return $timestamp <= PHP_INT_MAX && $timestamp >= ~PHP_INT_MAX;
    }

    /**
     * Returns true if given date is today.
     *
     * @param mixed $date The timestamp to check
     * @return bool true if date is today, false otherwise.
     */
    public static function isToday($date): bool
    {
        $date = self::toDateTime($date);
        $now = new DateTime();

        return $date->format('Y-m-d') == $now->format('Y-m-d');
    }

    /**
     * Returns true if given date was yesterday
     *
     * @param mixed $date The timestamp to check
     * @return bool true if date was yesterday, false otherwise.
     */
    public static function isYesterday($date): bool
    {
        $date = self::toDateTime($date);
        $yesterday = new DateTime('yesterday', new DateTimeZone(Craft::$app->getTimeZone()));

        return $date->format('Y-m-d') == $yesterday->format('Y-m-d');
    }

    /**
     * Returns true if given date is in this year
     *
     * @param mixed $date The timestamp to check
     * @return bool true if date is in this year, false otherwise.
     */
    public static function isThisYear($date): bool
    {
        $date = self::toDateTime($date);
        $now = new DateTime();

        return $date->format('Y') == $now->format('Y');
    }

    /**
     * Returns true if given date is in this week
     *
     * @param mixed $date The timestamp to check
     * @return bool true if date is in this week, false otherwise.
     */
    public static function isThisWeek($date): bool
    {
        $date = self::toDateTime($date);
        $now = new DateTime();

        return $date->format('W Y') == $now->format('W Y');
    }

    /**
     * Returns true if given date is in this month
     *
     * @param mixed $date The timestamp to check
     * @return bool True if date is in this month, false otherwise.
     */
    public static function isThisMonth($date): bool
    {
        $date = self::toDateTime($date);
        $now = new DateTime();

        return $date->format('m Y') == $now->format('m Y');
    }

    /**
     * Returns true if specified datetime was within the interval specified, else false.
     *
     * @param mixed $date The timestamp to check
     * @param mixed $timeInterval The numeric value with space then time type.
     * Example of valid types: '6 hours', '2 days', '1 minute'.
     * @return bool Whether the $dateString was within the specified $timeInterval.
     */
    public static function isWithinLast($date, $timeInterval): bool
    {
        if (is_numeric($timeInterval)) {
            $timeInterval .= ' days';
        }

        $date = self::toDateTime($date);
        $timestamp = $date->getTimestamp();

        // Bail early if it's in the future
        if ($timestamp > time()) {
            return false;
        }

        $earliestTimestamp = strtotime('-' . $timeInterval);

        return ($timestamp >= $earliestTimestamp);
    }

    /**
     * Returns true if the specified date was in the past, otherwise false.
     *
     * @param mixed $date The timestamp to check
     * @return bool true if the specified date was in the past, false otherwise.
     */
    public static function isInThePast($date): bool
    {
        $date = self::toDateTime($date);

        return $date->getTimestamp() < time();
    }

    /**
     * Creates a DateInterval object based on a given number of seconds.
     *
     * @param int $seconds
     * @return DateInterval
     */
    public static function secondsToInterval(int $seconds): DateInterval
    {
        return new DateInterval("PT{$seconds}S");
    }

    /**
     * Returns the number of seconds that a given DateInterval object spans.
     *
     * @param DateInterval $dateInterval
     * @return int
     */
    public static function intervalToSeconds(DateInterval $dateInterval): int
    {
        $reference = new DateTimeImmutable();
        $endTime = $reference->add($dateInterval);

        return $endTime->getTimestamp() - $reference->getTimestamp();
    }

    /**
     * Returns true if interval string is a valid interval.
     *
     * @param string $intervalString
     * @return bool
     */
    public static function isValidIntervalString(string $intervalString): bool
    {
        $interval = DateInterval::createFromDateString($intervalString);

        return $interval->s != 0 || $interval->i != 0 || $interval->h != 0 || $interval->d != 0 || $interval->m != 0 || $interval->y != 0;
    }

    /**
     * Returns the interval in a human-friendly string.
     *
     * @param DateInterval $dateInterval
     * @param bool $showSeconds
     * @return string
     */
    public static function humanDurationFromInterval(DateInterval $dateInterval, bool $showSeconds = true): string
    {
        $timeComponents = [];

        if ($dateInterval->y) {
            $timeComponents[] = $dateInterval->y == 1 ? Craft::t('app', '1 year') : Craft::t('app', '{num} years', ['num' => $dateInterval->y]);
        }

        if ($dateInterval->m) {
            $timeComponents[] = $dateInterval->m == 1 ? Craft::t('app', '1 month') : Craft::t('app', '{num} months', ['num' => $dateInterval->m]);
        }

        if ($dateInterval->d) {
            $timeComponents[] = $dateInterval->d == 1 ? Craft::t('app', '1 day') : Craft::t('app', '{num} days', ['num' => $dateInterval->d]);
        }

        if ($dateInterval->h) {
            $timeComponents[] = $dateInterval->h == 1 ? Craft::t('app', '1 hour') : Craft::t('app', '{num} hours', ['num' => $dateInterval->h]);
        }

        $minutes = $dateInterval->i;

        if (!$showSeconds) {
            if ($minutes && round($dateInterval->s / 60)) {
                $minutes++;
            } else if (!$dateInterval->y && !$dateInterval->m && !$dateInterval->d && !$dateInterval->h && !$minutes) {
                return Craft::t('app', 'less than a minute');
            }
        }

        if ($minutes) {
            $timeComponents[] = $minutes == 1 ? Craft::t('app', '1 minute') : Craft::t('app', '{num} minutes', ['num' => $minutes]);
        }

        if ($showSeconds && $dateInterval->s) {
            $timeComponents[] = $dateInterval->s == 1 ? Craft::t('app', '1 second') : Craft::t('app', '{num} seconds', ['num' => $dateInterval->s]);
        }

        $last = array_pop($timeComponents);
        if (!empty($timeComponents)) {
            $string = implode(', ', $timeComponents);
            if (count($timeComponents) > 1) {
                $string .= ',';
            }
            $string .= ' ' . Craft::t('app', 'and') . ' ';
        } else {
            $string = '';
        }
        $string .= $last;
        return $string;
    }

    // Private Methods
    // =========================================================================

    /**
     * Normalizes and returns a date string along with the format it was set in.
     *
     * @param string $value
     * @return array
     */
    private static function _parseDate(string $value): array
    {
        $value = trim($value);

        // First see if it's in YYYY-MM-DD format
        if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
            return [$value, 'Y-m-d'];
        }

        // Get the locale's short date format
        $format = Craft::$app->getLocale()->getDateFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP);

        // Make sure it's a 4-digit year
        $format = StringHelper::replace($format, 'y', 'Y');

        // Valid separators are either '-', '.' or '/'.
        if (StringHelper::contains($format, '.')) {
            $separator = '.';
        } else if (StringHelper::contains($format, '-')) {
            $separator = '-';
        } else {
            $separator = '/';
        }

        // Ensure that the submitted date is using the locale’s separator
        $date = strtr($value, '-./', str_repeat($separator, 3));

        // Two-digit year?
        $altFormat = StringHelper::replace($format, 'Y', 'y');
        if (DateTime::createFromFormat($altFormat, $date) !== false) {
            return [$date, $altFormat];
        }

        return [$date, $format];
    }

    /**
     * Normalizes and returns a time string along with the format it was set in
     *
     * @param string $value
     * @return array
     */
    private static function _parseTime(string $value): array
    {
        $value = trim($value);

        // First see if it's in HH:MM format
        if (preg_match('/^\d{2}:\d{2}$/', $value)) {
            return [$value, 'H:i'];
        }

        // Get the locale's short time format
        $locale = Craft::$app->getLocale();
        $format = $locale->getTimeFormat(Locale::LENGTH_SHORT, Locale::FORMAT_PHP);

        // Replace the localized "AM" and "PM"
        $am = $locale->getAMName();
        $pm = $locale->getPMName();

        if (preg_match('/(.*)(' . preg_quote($am, '/') . '|' . preg_quote($pm, '/') . ')(.*)/iu', $value, $matches)) {
            $value = $matches[1] . $matches[3];

            if (mb_strtolower($matches[2]) === mb_strtolower($am)) {
                $value .= 'AM';
            } else {
                $value .= 'PM';
            }

            $format = str_replace('A', '', $format) . 'A';
        }

        return [$value, $format];
    }

    /**
     * Normalizes and returns a date & time string along with the format it was set in.
     *
     * @param string $value
     * @param string $defaultTimeZone
     * @return array
     */
    private static function _parseDateTime(string $value, string $defaultTimeZone): array
    {
        $value = trim($value);

        if (preg_match('/^
                (?P<year>\d{4})                                  # YYYY (four digit year)
                (?:
                    -(?P<mon>\d\d?)                              # -M or -MM (1 or 2 digit month)
                    (?:
                        -(?P<day>\d\d?)                          # -D or -DD (1 or 2 digit day)
                        (?:
                            [T\ ](?P<hour>\d\d?)\:(?P<min>\d\d)  # [T or space]hh:mm (1 or 2 digit hour and 2 digit minute)
                            (?:
                                \:(?P<sec>\d\d)                  # :ss (two digit second)
                                (?:\.\d+)?                       # .s (decimal fraction of a second -- not supported)
                            )?
                            (?:[ ]?(?P<ampm>(AM|PM|am|pm))?)?    # An optional space and AM or PM
                            (?P<tz>Z|(?P<tzd>[+\-]\d\d\:?\d\d))? # Z or [+ or -]hh(:)ss (UTC or a timezone offset)
                        )?
                    )?
                )?$/x', $value, $m)) {
            $format = 'Y-m-d H:i:s';

            $date = $m['year'] .
                '-' . (!empty($m['mon']) ? sprintf('%02d', $m['mon']) : '01') .
                '-' . (!empty($m['day']) ? sprintf('%02d', $m['day']) : '01') .
                ' ' . (!empty($m['hour']) ? sprintf('%02d', $m['hour']) : '00') .
                ':' . (!empty($m['min']) ? $m['min'] : '00') .
                ':' . (!empty($m['sec']) ? $m['sec'] : '00');

            if (!empty($m['ampm'])) {
                $format .= ' A';
                $date .= ' ' . $m['ampm'];
            }

            // Did they specify a timezone?
            if (!empty($m['tz'])) {
                if (!empty($m['tzd'])) {
                    $format .= strpos($m['tzd'], ':') !== false ? 'P' : 'O';
                    $date .= $m['tzd'];
                } else {
                    // "Z" = UTC
                    $format .= 'e';
                    $date .= 'UTC';
                }
            } else {
                $format .= 'e';
                $date .= $defaultTimeZone;
            }

            return [$date, $format];
        }

        if (static::isValidTimeStamp($value)) {
            return [$value, 'U'];
        }

        return [$value, false];
    }

    /**
     * Returns translation pairs for [[translateDate()]].
     *
     * @param string $language The target language
     * @return array The translation pairs
     */
    private static function _getDateTranslations(string $language): array
    {
        if (!isset(self::$_translationPairs[$language])) {
            if (strpos(Craft::$app->language, 'en') === 0) {
                $sourceLocale = Craft::$app->getLocale();
            } else {
                $sourceLocale = Craft::$app->getI18n()->getLocaleById('en-US');
            }

            $targetLocale = Craft::$app->getI18n()->getLocaleById($language);

            $amName = $targetLocale->getAMName();
            $pmName = $targetLocale->getPMName();

            self::$_translationPairs[$language] = array_merge(
                array_combine($sourceLocale->getMonthNames(Locale::LENGTH_FULL), $targetLocale->getMonthNames(Locale::LENGTH_FULL)),
                array_combine($sourceLocale->getWeekDayNames(Locale::LENGTH_FULL), $targetLocale->getWeekDayNames(Locale::LENGTH_FULL)),
                array_combine($sourceLocale->getMonthNames(Locale::LENGTH_MEDIUM), $targetLocale->getMonthNames(Locale::LENGTH_MEDIUM)),
                array_combine($sourceLocale->getWeekDayNames(Locale::LENGTH_MEDIUM), $targetLocale->getWeekDayNames(Locale::LENGTH_MEDIUM)),
                [
                    'AM' => mb_strtoupper($amName),
                    'PM' => mb_strtoupper($pmName),
                    'am' => mb_strtolower($amName),
                    'pm' => mb_strtolower($pmName)
                ]
            );
        }

        return self::$_translationPairs[$language];
    }
}
