/*
 * Copyright (c) 2007-present, Stephen Colebourne & Michael Nascimento Santos
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  * Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 *  * Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 *
 *  * Neither the name of JSR-310 nor the names of its contributors
 *    may be used to endorse or promote products derived from this software
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
 * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
 * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
 * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.threeten.extra.chrono;

import static java.time.temporal.ChronoField.ALIGNED_DAY_OF_WEEK_IN_MONTH;
import static java.time.temporal.ChronoField.ALIGNED_DAY_OF_WEEK_IN_YEAR;
import static java.time.temporal.ChronoField.ALIGNED_WEEK_OF_MONTH;
import static java.time.temporal.ChronoField.ALIGNED_WEEK_OF_YEAR;
import static java.time.temporal.ChronoField.ERA;
import static java.time.temporal.ChronoField.YEAR;

import java.time.chrono.ChronoLocalDate;
import java.time.chrono.ChronoPeriod;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalField;
import java.time.temporal.TemporalUnit;
import java.time.temporal.UnsupportedTemporalTypeException;
import java.time.temporal.ValueRange;

/**
 * An abstract date based on a year, month and day.
 *
 * <h3>Implementation Requirements</h3>
 * Implementations must be immutable and thread-safe.
 */
abstract class AbstractDate
        implements ChronoLocalDate {

    /**
     * Creates an instance.
     */
    AbstractDate() {
    }

    //-----------------------------------------------------------------------
    abstract int getProlepticYear();

    abstract int getMonth();

    abstract int getDayOfMonth();

    abstract int getDayOfYear();

    AbstractDate withDayOfYear(int value) {
        return plusDays(value - getDayOfYear());
    }

    int lengthOfWeek() {
        return 7;
    }

    int lengthOfYearInMonths() {
        return 12;
    }

    abstract ValueRange rangeAlignedWeekOfMonth();

    abstract AbstractDate resolvePrevious(int newYear, int newMonth, int dayOfMonth);

    AbstractDate resolveEpochDay(long epochDay) {
        return (AbstractDate) getChronology().dateEpochDay(epochDay);
    }

    //-----------------------------------------------------------------------
    @Override
    public ValueRange range(TemporalField field) {
        if (field instanceof ChronoField) {
            if (isSupported(field)) {
                return rangeChrono((ChronoField) field);
            }
            throw new UnsupportedTemporalTypeException("Unsupported field: " + field);
        }
        return field.rangeRefinedBy(this);
    }

    ValueRange rangeChrono(ChronoField field) {
        switch (field) {
            case DAY_OF_MONTH:
                return ValueRange.of(1, lengthOfMonth());
            case DAY_OF_YEAR:
                return ValueRange.of(1, lengthOfYear());
            case ALIGNED_WEEK_OF_MONTH:
                return rangeAlignedWeekOfMonth();
            default:
                break;
        }
        return getChronology().range(field);
    }

    //-----------------------------------------------------------------------
    @Override
    public long getLong(TemporalField field) {
        if (field instanceof ChronoField) {
            switch ((ChronoField) field) {
                case DAY_OF_WEEK:
                    return getDayOfWeek();
                case ALIGNED_DAY_OF_WEEK_IN_MONTH:
                    return getAlignedDayOfWeekInMonth();
                case ALIGNED_DAY_OF_WEEK_IN_YEAR:
                    return getAlignedDayOfWeekInYear();
                case DAY_OF_MONTH:
                    return getDayOfMonth();
                case DAY_OF_YEAR:
                    return getDayOfYear();
                case EPOCH_DAY:
                    return toEpochDay();
                case ALIGNED_WEEK_OF_MONTH:
                    return getAlignedWeekOfMonth();
                case ALIGNED_WEEK_OF_YEAR:
                    return getAlignedWeekOfYear();
                case MONTH_OF_YEAR:
                    return getMonth();
                case PROLEPTIC_MONTH:
                    return getProlepticMonth();
                case YEAR_OF_ERA:
                    return getYearOfEra();
                case YEAR:
                    return getProlepticYear();
                case ERA:
                    return (getProlepticYear() >= 1 ? 1 : 0);
                default:
                    break;
            }
            throw new UnsupportedTemporalTypeException("Unsupported field: " + field);
        }
        return field.getFrom(this);
    }

    int getAlignedDayOfWeekInMonth() {
        return ((getDayOfMonth() - 1) % lengthOfWeek()) + 1;
    }

    int getAlignedDayOfWeekInYear() {
        return ((getDayOfYear() - 1) % lengthOfWeek()) + 1;
    }

    int getAlignedWeekOfMonth() {
        return ((getDayOfMonth() - 1) / lengthOfWeek()) + 1;
    }

    int getAlignedWeekOfYear() {
        return ((getDayOfYear() - 1) / lengthOfWeek()) + 1;
    }

    int getDayOfWeek() {
        return (int) (Math.floorMod(toEpochDay() + 3, 7) + 1);
    }

    long getProlepticMonth() {
        return getProlepticYear() * lengthOfYearInMonths() + getMonth() - 1;
    }

    int getYearOfEra() {
        return getProlepticYear() >= 1 ? getProlepticYear() : 1 - getProlepticYear();
    }

    //-------------------------------------------------------------------------
    @Override
    public AbstractDate with(TemporalField field, long newValue) {
        if (field instanceof ChronoField) {
            ChronoField f = (ChronoField) field;
            getChronology().range(f).checkValidValue(newValue, f);
            int nvalue = (int) newValue;
            switch (f) {
                case DAY_OF_WEEK:
                    return plusDays(newValue - getDayOfWeek());
                case ALIGNED_DAY_OF_WEEK_IN_MONTH:
                    return plusDays(newValue - getLong(ALIGNED_DAY_OF_WEEK_IN_MONTH));
                case ALIGNED_DAY_OF_WEEK_IN_YEAR:
                    return plusDays(newValue - getLong(ALIGNED_DAY_OF_WEEK_IN_YEAR));
                case DAY_OF_MONTH:
                    return resolvePrevious(getProlepticYear(), getMonth(), nvalue);
                case DAY_OF_YEAR:
                    return withDayOfYear(nvalue);
                case EPOCH_DAY:
                    return resolveEpochDay(newValue);
                case ALIGNED_WEEK_OF_MONTH:
                    return plusDays((newValue - getLong(ALIGNED_WEEK_OF_MONTH)) * lengthOfWeek());
                case ALIGNED_WEEK_OF_YEAR:
                    return plusDays((newValue - getLong(ALIGNED_WEEK_OF_YEAR)) * lengthOfWeek());
                case MONTH_OF_YEAR:
                    return resolvePrevious(getProlepticYear(), nvalue, getDayOfMonth());
                case PROLEPTIC_MONTH:
                    return plusMonths(newValue - getProlepticMonth());
                case YEAR_OF_ERA:
                    return resolvePrevious(getProlepticYear() >= 1 ? nvalue : 1 - nvalue, getMonth(), getDayOfMonth());
                case YEAR:
                    return resolvePrevious(nvalue, getMonth(), getDayOfMonth());
                case ERA:
                    return newValue == getLong(ERA) ? this : resolvePrevious(1 - getProlepticYear(), getMonth(), getDayOfMonth());
                default:
                    break;
            }
            throw new UnsupportedTemporalTypeException("Unsupported field: " + field);
        }
        return field.adjustInto(this, newValue);
    }

    @Override
    public AbstractDate plus(long amountToAdd, TemporalUnit unit) {
        if (unit instanceof ChronoUnit) {
            ChronoUnit f = (ChronoUnit) unit;
            switch (f) {
                case DAYS:
                    return plusDays(amountToAdd);
                case WEEKS:
                    return plusWeeks(amountToAdd);
                case MONTHS:
                    return plusMonths(amountToAdd);
                case YEARS:
                    return plusYears(amountToAdd);
                case DECADES:
                    return plusYears(Math.multiplyExact(amountToAdd, 10));
                case CENTURIES:
                    return plusYears(Math.multiplyExact(amountToAdd, 100));
                case MILLENNIA:
                    return plusYears(Math.multiplyExact(amountToAdd, 1000));
                case ERAS:
                    return with(ERA, Math.addExact(getLong(ERA), amountToAdd));
                default:
                    break;
            }
            throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
        }
        return unit.addTo(this, amountToAdd);
    }

    AbstractDate plusYears(long yearsToAdd) {
        if (yearsToAdd == 0) {
            return this;
        }
        int newYear = YEAR.checkValidIntValue(Math.addExact(getProlepticYear(), yearsToAdd));
        return resolvePrevious(newYear, getMonth(), getDayOfMonth());
    }

    AbstractDate plusMonths(long months) {
        if (months == 0) {
            return this;
        }
        long curEm = getProlepticMonth();
        long calcEm = Math.addExact(curEm, months);
        int newYear = Math.toIntExact(Math.floorDiv(calcEm, lengthOfYearInMonths()));
        int newMonth = (int) (Math.floorMod(calcEm, lengthOfYearInMonths()) + 1);
        return resolvePrevious(newYear, newMonth, getDayOfMonth());
    }

    AbstractDate plusWeeks(long amountToAdd) {
        return plusDays(Math.multiplyExact(amountToAdd, lengthOfWeek()));
    }

    AbstractDate plusDays(long days) {
        if (days == 0) {
            return this;
        }
        return resolveEpochDay(Math.addExact(toEpochDay(), days));
    }

    //-------------------------------------------------------------------------
    long until(AbstractDate end, TemporalUnit unit) {
        if (unit instanceof ChronoUnit) {
            switch ((ChronoUnit) unit) {
                case DAYS:
                    return daysUntil(end);
                case WEEKS:
                    return weeksUntil(end);
                case MONTHS:
                    return monthsUntil(end);
                case YEARS:
                    return monthsUntil(end) / lengthOfYearInMonths();
                case DECADES:
                    return monthsUntil(end) / (lengthOfYearInMonths() * 10);
                case CENTURIES:
                    return monthsUntil(end) / (lengthOfYearInMonths() * 100);
                case MILLENNIA:
                    return monthsUntil(end) / (lengthOfYearInMonths() * 1000);
                case ERAS:
                    return end.getLong(ERA) - getLong(ERA);
                default:
                    break;
            }
            throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
        }
        return unit.between(this, end);
    }

    long daysUntil(ChronoLocalDate end) {
        return end.toEpochDay() - toEpochDay();  // no overflow
    }

    long weeksUntil(AbstractDate end) {
        return daysUntil(end) / lengthOfWeek();
    }

    long monthsUntil(AbstractDate end) {
        long packed1 = getProlepticMonth() * 256L + getDayOfMonth();  // no overflow
        long packed2 = end.getProlepticMonth() * 256L + end.getDayOfMonth();  // no overflow
        return (packed2 - packed1) / 256L;
    }

    ChronoPeriod doUntil(AbstractDate end) {
        long totalMonths = end.getProlepticMonth() - this.getProlepticMonth();  // safe
        int days = end.getDayOfMonth() - this.getDayOfMonth();
        if (totalMonths > 0 && days < 0) {
            totalMonths--;
            AbstractDate calcDate = this.plusMonths(totalMonths);
            days = (int) (end.toEpochDay() - calcDate.toEpochDay());  // safe
        } else if (totalMonths < 0 && days > 0) {
            totalMonths++;
            days -= end.lengthOfMonth();
        }
        long years = totalMonths / lengthOfYearInMonths();  // safe
        int months = (int) (totalMonths % lengthOfYearInMonths());  // safe
        return getChronology().period(Math.toIntExact(years), months, days);
    }

    //-------------------------------------------------------------------------
    /**
     * Compares this date to another date, including the chronology.
     * <p>
     * Compares this date with another ensuring that the date is the same.
     * <p>
     * Only objects of this concrete type are compared, other types return false.
     * To compare the dates of two {@code TemporalAccessor} instances, including dates
     * in two different chronologies, use {@link ChronoField#EPOCH_DAY} as a comparator.
     *
     * @param obj  the object to check, null returns false
     * @return true if this is equal to the other date
     */
    @Override  // override for performance
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj != null && this.getClass() == obj.getClass()) {
            AbstractDate otherDate = (AbstractDate) obj;
            return this.getProlepticYear() == otherDate.getProlepticYear() &&
                    this.getMonth() == otherDate.getMonth() &&
                    this.getDayOfMonth() == otherDate.getDayOfMonth();
        }
        return false;
    }

    /**
     * A hash code for this date.
     *
     * @return a suitable hash code based only on the Chronology and the date
     */
    @Override  // override for performance
    public int hashCode() {
        return getChronology().getId().hashCode() ^
                ((getProlepticYear() & 0xFFFFF800) ^ ((getProlepticYear() << 11) +
                        (getMonth() << 6) + (getDayOfMonth())));
    }

    @Override
    public String toString() {
        StringBuilder buf = new StringBuilder(30);
        buf.append(getChronology().toString())
                .append(" ")
                .append(getEra())
                .append(" ")
                .append(getYearOfEra())
                .append(getMonth() < 10 ? "-0" : "-").append(getMonth())
                .append(getDayOfMonth() < 10 ? "-0" : "-").append(getDayOfMonth());
        return buf.toString();
    }

}
