<?php

/**
 * Time entity.
 *
 * @instantiator new
 * @author       Time.ly Network, Inc.
 * @since        2.0
 * @package      Ai1EC
 * @subpackage   Ai1EC.Date
 */
class Ai1ec_Date_Time {

    /**
     * @var Ai1ec_Registry_Object Instance of objects registry.
     */
    protected $_registry  = null;

    /**
     * @var DateTime Instance of date time object used to perform manipulations.
     */
    protected $_date_time = null;

    /**
     * @var string Olsen name of preferred timezone to use if none is requested.
     */
    protected $_preferred_timezone = null;

    /**
     * @var bool Set to true when `no value` is set.
     */
    protected $_is_empty = false;

    /**
     * Initialize local date entity.
     *
     * @param Ai1ec_Registry_Object $registry Objects registry instance.
     * @param string                $time     For details {@see self::format}.
     * @param string                $timezone For details {@see self::format}.
     *
     * @return void
     */
    public function __construct(
        Ai1ec_Registry_Object $registry,
        $time     = 'now',
        $timezone = 'UTC'
    ) {
        $this->_registry = $registry;
        $this->set_date_time( $time, $timezone );
    }

    /**
     * Since clone is shallow, we need to clone the DateTime object
     */
    public function __clone() {
        $this->_date_time = clone $this->_date_time;
    }

    /**
     * Return formatted date in desired timezone.
     *
     * NOTICE: consider optimizing by storing multiple copies of `DateTime` for
     * each requested timezone, or some of them, as of now timezone is changed
     * back and forth every time when formatting is called for.
     *
     * @param string $format   Desired format as accepted by {@see date}.
     * @param string $timezone Valid timezone identifier. Defaults to current.
     *
     * @return string Formatted date time.
     *
     * @throws Ai1ec_Date_Timezone_Exception If timezone is not recognized.
     */
    public function format( $format = 'U', $timezone = null ) {
        if ( $this->_is_empty ) {
            return null;
        }
        if ( 'U' === $format ) { // performance cut
            return $this->_date_time->format( 'U' );
        }
        $timezone  = $this->get_default_format_timezone( $timezone );
        $last_tz   = $this->get_timezone();
        $this->set_timezone( $timezone );
        $formatted = $this->_date_time->format( $format );
        $this->set_timezone( $last_tz );
        return $formatted;
    }

    /**
     * Format date time to i18n representation.
     *
     * @param string $format   Target I18n format.
     * @param string $timezone Valid timezone identifier. Defaults to current.
     *
     * @return string Formatted time.
     */
    public function format_i18n( $format, $timezone = null ) {
        $parser    = $this->_registry->get( 'parser.date' );
        $parsed    = $parser->get_format( $format );
        $inflected = $this->format( $parsed, $timezone );
        $formatted = $parser->squeeze( $inflected );
        return $formatted;
    }

    /**
     * Commodity method to format to UTC.
     *
     * @param string $format Target format, defaults to UNIX timestamp.
     *
     * @return string Formatted datetime string.
     */
    public function format_to_gmt( $format = 'U' ) {
        return $this->format( $format, 'UTC' );
    }

    /**
     * Create JavaScript ready date/time information string.
     *
     * @param bool $event_timezone Set to true to format in event timezone.
     *
     * @return string JavaScript date/time string.
     */
    public function format_to_javascript( $event_timezone = false ) {
        $event_timezone = ( $event_timezone )
            ? $this->get_timezone()
            : null;
        return $this->format( 'Y-m-d\TH:i:s', $event_timezone );
    }

    /**
     * Get timezone to use when format doesn't have one.
     *
     * Precedence:
     *     1. Timezone supplied for formatting;
     *     2. Objects preferred timezone;
     *     3. Default systems timezone.
     *
     * @var string $timezone Requested formatting timezone.
     *
     * @return string Olsen timezone name to use.
     */
    public function get_default_format_timezone( $timezone = null ) {
        if ( null !== $timezone ) {
            return $timezone;
        }
        if ( null !== $this->_preferred_timezone ) {
            return $this->_preferred_timezone;
        }
        return $this->_registry->get( 'date.timezone' )
            ->get_default_timezone();
    }

    /**
     * Offset from GMT in minutes.
     *
     * @return int Signed integer - offset.
     */
    public function get_gmt_offset() {
        return $this->_date_time->getOffset() / 60;
    }

    /**
     * Returns timezone offset as human readable GMT string.
     *
     * @return string
     */
    public function get_gmt_offset_as_text() {
        $offset        = $this->_date_time->getOffset();
        $offsetHours   = $offset / 3600;
        $offset        = $offset % 3600;
        $offsetMinutes = abs( $offset ) / 60;
        return sprintf( '(GMT%+03d:%02d)', $offsetHours, $offsetMinutes );
    }

    /**
     * Set preferred timezone to use when format is called without any.
     *
     * @param DateTimeZone $timezone Preferred timezone instance.
     *
     * @return Ai1ec_Date_Time Instance of self for chaining.
     */
    public function set_preferred_timezone( $timezone ) {
        if ( $timezone instanceof DateTimeZone ) {
            $timezone = $timezone->getName();
        }
        $this->_preferred_timezone = (string)$timezone;
        return $this;
    }

    /**
     * Change timezone of stored entity.
     *
     * @param string $timezone Valid timezone identifier.
     *
     * @return Ai1ec_Date Instance of self for chaining.
     *
     * @throws Ai1ec_Date_Timezone_Exception If timezone is not recognized.
     */
    public function set_timezone( $timezone = 'UTC' ) {
        $date_time_tz = ( $timezone instanceof DateTimeZone )
            ? $timezone
            : $this->_registry->get( 'date.timezone' )->get( $timezone );
        $this->_date_time->setTimezone( $date_time_tz );
        return $this;
    }

    /**
     * Get timezone associated with current object.
     *
     * @return string|null Valid PHP timezone string or null on error.
     */
    public function get_timezone() {
        $timezone = $this->_date_time->getTimezone();
        if ( false === $timezone ) {
            return null;
        }
        return $timezone->getName();
    }

    /**
     * Get difference in seconds between to dates.
     *
     * In PHP versions post 5.3.0 the {@see DateTimeImmutable::diff()} is
     * used. In earlier versions the difference between two timestamps is
     * being checked.
     *
     * @param Ai1ec_Date_Time $comparable Other date time entity.
     *
     * @return int Number of seconds between two dates.
     */
    public function diff_sec( Ai1ec_Date_Time $comparable, $timezone = null ) {
        // NOTICE: `$this->_is_empty` is not touched here intentionally
        // because there is no meaningful difference to `empty` value.
        // It is left to be handled at upper level - you are not likely to
        // reach situation where you compare something against empty value.
        if ( version_compare( PHP_VERSION, '5.3.0' ) < 0 ) {
            $difference = $this->_date_time->format( 'U' ) -
                $comparable->_date_time->format( 'U' );
            if ( $difference < 0 ) {
                $difference *= -1;
            }
            return $difference;
        }
        $difference = $this->_date_time->diff( $comparable->_date_time, true );
        return (
            $difference->days * 86400 +
            $difference->h    * 3600  +
            $difference->i    * 60    +
            $difference->s
        );
    }

    /**
     * Adjust only date fragment of entity.
     *
     * @param int $year  Year of the date.
     * @param int $month Month of the date.
     * @param int $day   Day of the date.
     *
     * @return Ai1ec_Date_Time Instance of self for chaining.
     */
    public function set_date( $year, $month, $day ) {
        $this->_date_time->setDate( $year, $month, $day );
        $this->_is_empty = false;
        return $this;
    }

    /**
     * Adjust only time fragment of entity.
     *
     * @param int $hour   Hour of the time.
     * @param int $minute Minute of the time.
     * @param int $second Second of the time.
     *
     * @return Ai1ec_Date_Time Instance of self for chaining.
     */
    public function set_time( $hour, $minute = 0, $second = 0 ) {
        $this->_date_time->setTime( $hour, $minute, $second );
        $this->_is_empty = false;
        return $this;
    }

    /**
     * Adjust day part of date time entity.
     *
     * @param int $quantifier Day adjustment quantifier.
     *
     * @return Ai1ec_Date_Time Instance of self for chaining.
     */
    public function adjust_day( $quantifier ) {
        // NOTICE: `$this->_is_empty` is not touched here, because if you
        // start adjusting value it's likely not empty by then.
        $this->adjust( $quantifier, 'day' );
        return $this;
    }

    /**
     * Adjust day part of date time entity.
     *
     * @param int $quantifier Day adjustment quantifier.
     *
     * @return Ai1ec_Date_Time Instance of self for chaining.
     */
    public function adjust_month( $quantifier ) {
        $this->adjust( $quantifier, 'month' );
        return $this;
    }

    /**
     * Change/initiate stored date time entity.
     *
     * NOTICE: time specifiers falling in range 0..2048 will be treated
     * as a UNIX timestamp, to full format specification, thus ignoring
     * any value passed for timezone.
     *
     * @param string $time     Valid (PHP-parseable) date/time identifier.
     * @param string $timezone Valid timezone identifier.
     *
     * @return Ai1ec_Date Instance of self for chaining.
     */
    public function set_date_time( $time = 'now', $timezone = 'UTC' ) {
        if ( $time instanceof self ) {
            $this->_is_empty           = $time->_is_empty;
            $this->_date_time          = clone $time->_date_time;
            $this->_preferred_timezone = $time->_preferred_timezone;
            if ( 'UTC' !== $timezone && $timezone ) {
                $this->set_timezone( $timezone );
            }
            return $this;
        }
        $this->assert_utc_timezone();
        $date_time_tz = $this->_registry->get( 'date.timezone' )
                ->get( $timezone );
        $reset_tz     = false;
        $this->_is_empty = false;
        if ( null === $time ) {
            $this->_is_empty = true;
            $time            = '@' . ~PHP_INT_MAX;
            $reset_tz        = true;
        } else if ( $this->is_timestamp( $time ) ) {
            $time     = '@' . $time; // treat as UNIX timestamp
            $reset_tz = true; // store intended TZ
        }
        // PHP <= 5.3.5 compatible
        $this->_date_time = new DateTime( $time, $date_time_tz );
        if ( $reset_tz ) {
            $this->set_timezone( $date_time_tz );
        }
        return $this;
    }

    /**
     * Check if value should be treated as a UNIX timestamp.
     *
     * @param string $time Provided time value.
     *
     * @return bool True if seems like UNIX timestamp.
     */
    public function is_timestamp( $time ) {
        // '20001231T001559Z'
        if ( isset( $time[8] ) && 'T' === $time[8] ) {
            return false;
        }
        if ( (string)(int)$time !== (string)$time ) {
            return false;
        }
        // 1000..2459 are treated as hours, 2460..9999 - as years
        if ( $time > 999 && $time < 2460 ) {
            return false;
        }
        return true;

    }

    /**
     * Assert that current timezone is UTC.
     *
     * @return bool Success.
     */
    public function assert_utc_timezone() {
        $default = (string)date_default_timezone_get();
        $success = true;
        if ( 'UTC' !== $default ) {
            // issue admin notice
            $success = date_default_timezone_set( 'UTC' );
        }
        return $success;
    }

    /**
     * Magic method for compatibility.
     *
     * @return string ISO-8601 formatted date-time.
     */
    public function __toString() {
        return $this->format( 'c' );
    }

    /**
     * Modifies the DateTime object
     *
     * @param int $quantifier
     * @param string $longname
     */
    public function adjust( $quantifier, $longname ) {
        $quantifier = (int)$quantifier;
        if ( $quantifier > 0 && 0 !== strpos( $quantifier, '+' ) ) {
            $quantifier = '+' . $quantifier;
        }
        $modifier = $quantifier . ' ' . $longname;
        $this->_date_time->modify( $modifier );
        return $this;
    }

    /**
     * Explicitly check if value (date) is empty.
     *
     * @return bool Emptiness
     */
    public function is_empty() {
        return $this->_is_empty;
    }

}