/**
 * @copyright (c) 2016, Philipp Thürwächter & Pattrick Hüper
 * @copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
 * @license BSD-3-Clause (see LICENSE in the root directory of this source tree)
 */

import {requireNonNull} from './assert';
import {DateTimeException} from './errors';
import {MathUtil} from './MathUtil';

import {LocalTime} from './LocalTime';
import {ZoneId} from './ZoneId';

import {ChronoField} from './temporal/ChronoField';
import {TemporalQueries} from './temporal/TemporalQueries';

import {ZoneRules} from './zone/ZoneRules';

const SECONDS_CACHE = {};
const ID_CACHE = {};

/**
 *
 * ### Static properties of Class {@link LocalDate}
 *
 * ZoneOffset.MAX_SECONDS = 18 * LocalTime.SECONDS_PER_HOUR;
 *
 * ZoneOffset.UTC = ZoneOffset.ofTotalSeconds(0);
 *
 * ZoneOffset.MIN = ZoneOffset.ofTotalSeconds(-ZoneOffset.MAX_SECONDS);
 *
 * ZoneOffset.MAX = ZoneOffset.ofTotalSeconds(ZoneOffset.MAX_SECONDS);
 *
 */
export class ZoneOffset extends ZoneId {
    /**
     *
     * @param {number} totalSeconds
     * @private
     */
    constructor(totalSeconds){
        super();
        ZoneOffset._validateTotalSeconds(totalSeconds);
        this._totalSeconds = MathUtil.safeToInt(totalSeconds);
        this._rules = ZoneRules.of(this);
        this._id = ZoneOffset._buildId(totalSeconds);
    }

    /**
     *
     * @returns {number}
     */
    totalSeconds() {
        return this._totalSeconds;
    }

    /**
     *
     * @returns {string}
     */
    id() {
        return this._id;
    }

    /**
     *
     * @param {number} totalSeconds
     * @returns {string}
     */
    static _buildId(totalSeconds) {
        if (totalSeconds === 0) {
            return 'Z';
        } else {
            const absTotalSeconds = Math.abs(totalSeconds);
            const absHours = MathUtil.intDiv(absTotalSeconds, LocalTime.SECONDS_PER_HOUR);
            const absMinutes = MathUtil.intMod(MathUtil.intDiv(absTotalSeconds, LocalTime.SECONDS_PER_MINUTE), LocalTime.MINUTES_PER_HOUR);
            let buf = '' + (totalSeconds < 0 ? '-' : '+')
                + (absHours < 10 ? '0' : '') + (absHours)
                + (absMinutes < 10 ? ':0' : ':') + (absMinutes);
            const absSeconds = MathUtil.intMod(absTotalSeconds, LocalTime.SECONDS_PER_MINUTE);
            if (absSeconds !== 0) {
                buf += (absSeconds < 10 ? ':0' : ':') + (absSeconds);
            }
            return buf;
        }
    }


    /**
     *
     * @param {number} totalSeconds
     * @private
     */
    static _validateTotalSeconds(totalSeconds){
        if (Math.abs(totalSeconds) > ZoneOffset.MAX_SECONDS) {
            throw new DateTimeException('Zone offset not in valid range: -18:00 to +18:00');
        }
    }

    /**
     *
     * @param {number} hours
     * @param {number} minutes
     * @param {number} seconds
     * @private
     */
    static _validate(hours, minutes, seconds) {
        if (hours < -18 || hours > 18) {
            throw new DateTimeException('Zone offset hours not in valid range: value ' + hours +
                    ' is not in the range -18 to 18');
        }
        if (hours > 0) {
            if (minutes < 0 || seconds < 0) {
                throw new DateTimeException('Zone offset minutes and seconds must be positive because hours is positive');
            }
        } else if (hours < 0) {
            if (minutes > 0 || seconds > 0) {
                throw new DateTimeException('Zone offset minutes and seconds must be negative because hours is negative');
            }
        } else if ((minutes > 0 && seconds < 0) || (minutes < 0 && seconds > 0)) {
            throw new DateTimeException('Zone offset minutes and seconds must have the same sign');
        }
        if (Math.abs(minutes) > 59) {
            throw new DateTimeException('Zone offset minutes not in valid range: abs(value) ' +
                    Math.abs(minutes) + ' is not in the range 0 to 59');
        }
        if (Math.abs(seconds) > 59) {
            throw new DateTimeException('Zone offset seconds not in valid range: abs(value) ' +
                    Math.abs(seconds) + ' is not in the range 0 to 59');
        }
        if (Math.abs(hours) === 18 && (Math.abs(minutes) > 0 || Math.abs(seconds) > 0)) {
            throw new DateTimeException('Zone offset not in valid range: -18:00 to +18:00');
        }
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance of {@link ZoneOffset} using the ID.
     *
     * This method parses the string ID of a {@link ZoneOffset} to
     * return an instance. The parsing accepts all the formats generated by
     * {@link getId}, plus some additional formats:
     *
     * * {@link Z} - for UTC
     * * `+h`
     * * `+hh`
     * * `+hh:mm`
     * * `-hh:mm`
     * * `+hhmm`
     * * `-hhmm`
     * * `+hh:mm:ss`
     * * `-hh:mm:ss`
     * * `+hhmmss`
     * * `-hhmmss`
     *
     * Note that &plusmn; means either the plus or minus symbol.
     *
     * The ID of the returned offset will be normalized to one of the formats
     * described by {@link getId}.
     *
     * The maximum supported range is from +18:00 to -18:00 inclusive.
     *
     * @param {string} offsetId  the offset ID, not null
     * @return {ZoneOffset} the zone-offset, not null
     * @throws DateTimeException if the offset ID is invalid
     */
    static of(offsetId) {
        requireNonNull(offsetId, 'offsetId');
        // "Z" is always in the cache
        const offset = ID_CACHE[offsetId];
        if (offset != null) {
            return offset;
        }

        // parse - +h, +hh, +hhmm, +hh:mm, +hhmmss, +hh:mm:ss
        let hours, minutes, seconds;
        switch (offsetId.length) {
            case 2:
                offsetId = offsetId[0] + '0' + offsetId[1];  // fallthru
            // eslint-disable-next-line no-fallthrough
            case 3:
                hours = ZoneOffset._parseNumber(offsetId, 1, false);
                minutes = 0;
                seconds = 0;
                break;
            case 5:
                hours = ZoneOffset._parseNumber(offsetId, 1, false);
                minutes = ZoneOffset._parseNumber(offsetId, 3, false);
                seconds = 0;
                break;
            case 6:
                hours = ZoneOffset._parseNumber(offsetId, 1, false);
                minutes = ZoneOffset._parseNumber(offsetId, 4, true);
                seconds = 0;
                break;
            case 7:
                hours = ZoneOffset._parseNumber(offsetId, 1, false);
                minutes = ZoneOffset._parseNumber(offsetId, 3, false);
                seconds = ZoneOffset._parseNumber(offsetId, 5, false);
                break;
            case 9:
                hours = ZoneOffset._parseNumber(offsetId, 1, false);
                minutes = ZoneOffset._parseNumber(offsetId, 4, true);
                seconds = ZoneOffset._parseNumber(offsetId, 7, true);
                break;
            default:
                throw new DateTimeException('Invalid ID for ZoneOffset, invalid format: ' + offsetId);
        }
        const first = offsetId[0];
        if (first !== '+' && first !== '-') {
            throw new DateTimeException('Invalid ID for ZoneOffset, plus/minus not found when expected: ' + offsetId);
        }
        if (first === '-') {
            return ZoneOffset.ofHoursMinutesSeconds(-hours, -minutes, -seconds);
        } else {
            return ZoneOffset.ofHoursMinutesSeconds(hours, minutes, seconds);
        }
    }

    /**
     * Parse a two digit zero-prefixed number.
     *
     * @param {string} offsetId - the offset ID, not null
     * @param {number} pos - the position to parse, valid
     * @param {boolean} precededByColon - should this number be prefixed by a precededByColon
     * @return {number} the parsed number, from 0 to 99
     */
    static _parseNumber(offsetId, pos, precededByColon) {
        if (precededByColon && offsetId[pos - 1] !== ':') {
            throw new DateTimeException('Invalid ID for ZoneOffset, colon not found when expected: ' + offsetId);
        }
        const ch1 = offsetId[pos];
        const ch2 = offsetId[pos + 1];
        if (ch1 < '0' || ch1 > '9' || ch2 < '0' || ch2 > '9') {
            throw new DateTimeException('Invalid ID for ZoneOffset, non numeric characters found: ' + offsetId);
        }
        return (ch1.charCodeAt(0) - 48) * 10 + (ch2.charCodeAt(0) - 48);
    }

    /**
     *
     * @param {number} hours
     * @returns {ZoneOffset}
     */
    static ofHours(hours) {
        return ZoneOffset.ofHoursMinutesSeconds(hours, 0, 0);
    }

    /**
     *
     * @param {number} hours
     * @param {number} minutes
     * @returns {ZoneOffset}
     */
    static ofHoursMinutes(hours, minutes) {
        return ZoneOffset.ofHoursMinutesSeconds(hours, minutes, 0);
    }

    /**
     *
     * @param {number} hours
     * @param {number} minutes
     * @param {number} seconds
     * @returns {ZoneOffset}
     */
    static ofHoursMinutesSeconds(hours, minutes, seconds) {
        ZoneOffset._validate(hours, minutes, seconds);
        const totalSeconds = hours * LocalTime.SECONDS_PER_HOUR + minutes * LocalTime.SECONDS_PER_MINUTE + seconds;
        return ZoneOffset.ofTotalSeconds(totalSeconds);
    }

    /**
     *
     * @param {number} totalMinutes
     * @returns {ZoneOffset}
     */
    static ofTotalMinutes(totalMinutes) {
        const totalSeconds = totalMinutes * LocalTime.SECONDS_PER_MINUTE;
        return ZoneOffset.ofTotalSeconds(totalSeconds);
    }

    /**
     *
     * @param {number} totalSeconds
     * @returns {ZoneOffset}
     */
    static ofTotalSeconds(totalSeconds) {
        if (totalSeconds % (15 * LocalTime.SECONDS_PER_MINUTE) === 0) {
            const totalSecs = totalSeconds;
            let result = SECONDS_CACHE[totalSecs];
            if (result == null) {
                result = new ZoneOffset(totalSeconds);
                SECONDS_CACHE[totalSecs] = result;
                ID_CACHE[result.id()] = result;
            }
            return result;
        } else {
            return new ZoneOffset(totalSeconds);
        }
    }

    /**
     * Gets the associated time-zone rules.
     *
     * The rules will always return this offset when queried.
     * The implementation class is immutable, thread-safe and serializable.
     *
     * @return {ZoneRules} the rules, not null
     */
    rules() {
        return this._rules;
    }

    /**
      * Gets the value of the specified field from this offset as an `int`.
      *
      * This queries this offset for the value for the specified field.
      * The returned value will always be within the valid range of values for the field.
      * If it is not possible to return the value, because the field is not supported
      * or for some other reason, an exception is thrown.
      *
      * If the field is a {@link ChronoField} then the query is implemented here.
      * The {@link OFFSET_SECONDS} field returns the value of the offset.
      * All other {@link ChronoField} instances will throw a {@link DateTimeException}.
      *
      * If the field is not a {@link ChronoField}, then the result of this method
      * is obtained by invoking {@link TemporalField.getFrom}
      * passing `this` as the argument. Whether the value can be obtained,
      * and what the value represents, is determined by the field.
      *
      * @param {TemporalField} field - the field to get, not null
      * @return {number} the value for the field
      * @throws DateTimeException if a value for the field cannot be obtained
      * @throws ArithmeticException if numeric overflow occurs
      */
    get(field) {
        return this.getLong(field);
    }

    /**
      * Gets the value of the specified field from this offset as a `long`.
      *
      * This queries this offset for the value for the specified field.
      * If it is not possible to return the value, because the field is not supported
      * or for some other reason, an exception is thrown.
      *
      * If the field is a {@link ChronoField} then the query is implemented here.
      * The {@link OFFSET_SECONDS} field returns the value of the offset.
      * All other {@link ChronoField} instances will throw a {@link DateTimeException}.
      *
      * If the field is not a {@link ChronoField}, then the result of this method
      * is obtained by invoking {@link TemporalField.getFrom}
      * passing `this` as the argument. Whether the value can be obtained,
      * and what the value represents, is determined by the field.
      *
      * @param {TemporalField} field - the field to get, not null
      * @return {number} the value for the field
      * @throws DateTimeException if a value for the field cannot be obtained
      * @throws ArithmeticException if numeric overflow occurs
      */
    getLong(field) {
        if (field === ChronoField.OFFSET_SECONDS) {
            return this._totalSeconds;
        } else if (field instanceof ChronoField) {
            throw new DateTimeException('Unsupported field: ' + field);
        }
        return field.getFrom(this);
    }

    //-----------------------------------------------------------------------
    /**
      * Queries this offset using the specified query.
      *
      * This queries this offset using the specified query strategy object.
      * The {@link TemporalQuery} object defines the logic to be used to
      * obtain the result. Read the documentation of the query to understand
      * what the result of this method will be.
      *
      * The result of this method is obtained by invoking the
      * {@link TemporalQuery#queryFrom} method on the
      * specified query passing `this` as the argument.
      *
      * @param {TemporalQuery} query - the query to invoke, not null
      * @return {*} the query result, null may be returned (defined by the query)
      * @throws DateTimeException if unable to query (defined by the query)
      * @throws ArithmeticException if numeric overflow occurs (defined by the query)
      */
    query(query) {
        requireNonNull(query, 'query');
        if (query === TemporalQueries.offset() || query === TemporalQueries.zone()) {
            return this;
        } else if (query === TemporalQueries.localDate() || query === TemporalQueries.localTime() ||
                 query === TemporalQueries.precision() || query === TemporalQueries.chronology() || query === TemporalQueries.zoneId()) {
            return null;
        }
        return query.queryFrom(this);
    }

    /**
      * Adjusts the specified temporal object to have the same offset as this object.
      *
      * This returns a temporal object of the same observable type as the input
      * with the offset changed to be the same as this.
      *
      * The adjustment is equivalent to using {@link Temporal#with}
      * passing {@link ChronoField#OFFSET_SECONDS} as the field.
      *
      * In most cases, it is clearer to reverse the calling pattern by using
      * {@link Temporal#with}:
      * <pre>
      *   // these two lines are equivalent, but the second approach is recommended
      *   temporal = thisOffset.adjustInto(temporal);
      *   temporal = temporal.with(thisOffset);
      * </pre>
      *
      * This instance is immutable and unaffected by this method call.
      *
      * @param {Temporal} temporal - the target object to be adjusted, not null
      * @return {Temporal} the adjusted object, not null
      * @throws DateTimeException if unable to make the adjustment
      * @throws ArithmeticException if numeric overflow occurs
      */
    adjustInto(temporal) {
        return temporal.with(ChronoField.OFFSET_SECONDS, this._totalSeconds);
    }

    /**
     * Compares this offset to another offset in descending order.
     *
     * The offsets are compared in the order that they occur for the same time
     * of day around the world. Thus, an offset of `+10:00` comes before an
     * offset of `+09:00` and so on down to `-18:00`.
     *
     * The comparison is "consistent with equals", as defined by {@link Comparable}.
     *
     * @param {!ZoneOffset} other - the other date to compare to, not null
     * @return {number} the comparator value, negative if less, positive if greater
     * @throws NullPointerException if {@link other} is null
     */
    compareTo(other) {
        requireNonNull(other, 'other');
        return other._totalSeconds - this._totalSeconds;
    }


    /**
     * Checks if this offset is equal to another offset.
     *
     * The comparison is based on the amount of the offset in seconds.
     * This is equivalent to a comparison by ID.
     *
     * @param {*} obj - the object to check, null returns false
     * @return {boolean} true if this is equal to the other offset
     */
    equals(obj) {
        if (this === obj) {
            return true;
        }
        if (obj instanceof ZoneOffset) {
            return this._totalSeconds === obj._totalSeconds;
        }
        return false;
    }

    /**
     * @return {number}
     */
    hashCode(){
        return this._totalSeconds;
    }

    /**
     *
     * @returns {string}
     */
    toString(){
        return this._id;
    }
}

export function _init() {
    ZoneOffset.MAX_SECONDS = 18 * LocalTime.SECONDS_PER_HOUR;
    ZoneOffset.UTC = ZoneOffset.ofTotalSeconds(0);
    ZoneOffset.MIN = ZoneOffset.ofTotalSeconds(-ZoneOffset.MAX_SECONDS);
    ZoneOffset.MAX = ZoneOffset.ofTotalSeconds(ZoneOffset.MAX_SECONDS);
}
