/*
 *  Copyright 2001-2005 Stephen Colebourne
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package org.joda.time;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.joda.time.field.FieldUtils;

/**
 * Controls a period implementation by specifying which duration fields are to be used.
 * <p>
 * The following implementations are provided:
 * <ul>
 * <li>Standard - years, months, weeks, days, hours, minutes, seconds, millis
 * <li>YearMonthDayTime - years, months, days, hours, minutes, seconds, millis
 * <li>YearMonthDay - years, months, days
 * <li>YearWeekDayTime - years, weeks, days, hours, minutes, seconds, millis
 * <li>YearWeekDay - years, weeks, days
 * <li>YearDayTime - years, days, hours, minutes, seconds, millis
 * <li>YearDay - years, days, hours
 * <li>DayTime - days, hours, minutes, seconds, millis
 * <li>Time - hours, minutes, seconds, millis
 * <li>plus one for each single type
 * </ul>
 *
 * <p>
 * PeriodType is thread-safe and immutable, and all subclasses must be as well.
 *
 * @author Brian S O'Neill
 * @author Stephen Colebourne
 * @since 1.0
 */
public class PeriodType implements Serializable {
    /** Serialization version */
    private static final long serialVersionUID = 2274324892792009998L;

    /** Cache of all the known types. */
    private static final Map cTypes = new HashMap(32);

    static int YEAR_INDEX = 0;
    static int MONTH_INDEX = 1;
    static int WEEK_INDEX = 2;
    static int DAY_INDEX = 3;
    static int HOUR_INDEX = 4;
    static int MINUTE_INDEX = 5;
    static int SECOND_INDEX = 6;
    static int MILLI_INDEX = 7;
    
    private static PeriodType cStandard;
    private static PeriodType cYMDTime;
    private static PeriodType cYMD;
    private static PeriodType cYWDTime;
    private static PeriodType cYWD;
    private static PeriodType cYDTime;
    private static PeriodType cYD;
    private static PeriodType cDTime;
    private static PeriodType cTime;
    
    private static PeriodType cYears;
    private static PeriodType cMonths;
    private static PeriodType cWeeks;
    private static PeriodType cDays;
    private static PeriodType cHours;
    private static PeriodType cMinutes;
    private static PeriodType cSeconds;
    private static PeriodType cMillis;

    /**
     * Gets a type that defines all standard fields.
     * <ul>
     * <li>years
     * <li>months
     * <li>weeks
     * <li>days
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType standard() {
        PeriodType type = cStandard;
        if (type == null) {
            type = new PeriodType(
                "Standard",
                new DurationFieldType[] {
                    DurationFieldType.years(), DurationFieldType.months(),
                    DurationFieldType.weeks(), DurationFieldType.days(),
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { 0, 1, 2, 3, 4, 5, 6, 7, }
            );
            cStandard = type;
        }
        return type;
    }

    /**
     * Gets a type that defines all standard fields except weeks.
     * <ul>
     * <li>years
     * <li>months
     * <li>days
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType yearMonthDayTime() {
        PeriodType type = cYMDTime;
        if (type == null) {
            type = new PeriodType(
                "YearMonthDayTime",
                new DurationFieldType[] {
                    DurationFieldType.years(), DurationFieldType.months(),
                    DurationFieldType.days(),
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { 0, 1, -1, 2, 3, 4, 5, 6, }
            );
            cYMDTime = type;
        }
        return type;
    }

    /**
     * Gets a type that defines the year, month and day fields.
     * <ul>
     * <li>years
     * <li>months
     * <li>days
     * </ul>
     *
     * @return the period type
     * @since 1.1
     */
    public static PeriodType yearMonthDay() {
        PeriodType type = cYMD;
        if (type == null) {
            type = new PeriodType(
                "YearMonthDay",
                new DurationFieldType[] {
                    DurationFieldType.years(), DurationFieldType.months(),
                    DurationFieldType.days(),
                },
                new int[] { 0, 1, -1, 2, -1, -1, -1, -1, }
            );
            cYMD = type;
        }
        return type;
    }

    /**
     * Gets a type that defines all standard fields except months.
     * <ul>
     * <li>years
     * <li>weeks
     * <li>days
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType yearWeekDayTime() {
        PeriodType type = cYWDTime;
        if (type == null) {
            type = new PeriodType(
                "YearWeekDayTime",
                new DurationFieldType[] {
                    DurationFieldType.years(),
                    DurationFieldType.weeks(), DurationFieldType.days(),
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { 0, -1, 1, 2, 3, 4, 5, 6, }
            );
            cYWDTime = type;
        }
        return type;
    }

    /**
     * Gets a type that defines year, week and day fields.
     * <ul>
     * <li>years
     * <li>weeks
     * <li>days
     * </ul>
     *
     * @return the period type
     * @since 1.1
     */
    public static PeriodType yearWeekDay() {
        PeriodType type = cYWD;
        if (type == null) {
            type = new PeriodType(
                "YearWeekDay",
                new DurationFieldType[] {
                    DurationFieldType.years(),
                    DurationFieldType.weeks(), DurationFieldType.days(),
                },
                new int[] { 0, -1, 1, 2, -1, -1, -1, -1, }
            );
            cYWD = type;
        }
        return type;
    }

    /**
     * Gets a type that defines all standard fields except months and weeks.
     * <ul>
     * <li>years
     * <li>days
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType yearDayTime() {
        PeriodType type = cYDTime;
        if (type == null) {
            type = new PeriodType(
                "YearDayTime",
                new DurationFieldType[] {
                    DurationFieldType.years(), DurationFieldType.days(),
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { 0, -1, -1, 1, 2, 3, 4, 5, }
            );
            cYDTime = type;
        }
        return type;
    }

    /**
     * Gets a type that defines the year and day fields.
     * <ul>
     * <li>years
     * <li>days
     * </ul>
     *
     * @return the period type
     * @since 1.1
     */
    public static PeriodType yearDay() {
        PeriodType type = cYD;
        if (type == null) {
            type = new PeriodType(
                "YearDay",
                new DurationFieldType[] {
                    DurationFieldType.years(), DurationFieldType.days(),
                },
                new int[] { 0, -1, -1, 1, -1, -1, -1, -1, }
            );
            cYD = type;
        }
        return type;
    }

    /**
     * Gets a type that defines all standard fields from days downwards.
     * <ul>
     * <li>days
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType dayTime() {
        PeriodType type = cDTime;
        if (type == null) {
            type = new PeriodType(
                "DayTime",
                new DurationFieldType[] {
                    DurationFieldType.days(),
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { -1, -1, -1, 0, 1, 2, 3, 4, }
            );
            cDTime = type;
        }
        return type;
    }

    /**
     * Gets a type that defines all standard time fields.
     * <ul>
     * <li>hours
     * <li>minutes
     * <li>seconds
     * <li>milliseconds
     * </ul>
     *
     * @return the period type
     */
    public static PeriodType time() {
        PeriodType type = cTime;
        if (type == null) {
            type = new PeriodType(
                "Time",
                new DurationFieldType[] {
                    DurationFieldType.hours(), DurationFieldType.minutes(),
                    DurationFieldType.seconds(), DurationFieldType.millis(),
                },
                new int[] { -1, -1, -1, -1, 0, 1, 2, 3, }
            );
            cTime = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the years field.
     *
     * @return the period type
     */
    public static PeriodType years() {
        PeriodType type = cYears;
        if (type == null) {
            type = new PeriodType(
                "Years",
                new DurationFieldType[] { DurationFieldType.years() },
                new int[] { 0, -1, -1, -1, -1, -1, -1, -1, }
            );
            cYears = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the months field.
     *
     * @return the period type
     */
    public static PeriodType months() {
        PeriodType type = cMonths;
        if (type == null) {
            type = new PeriodType(
                "Months",
                new DurationFieldType[] { DurationFieldType.months() },
                new int[] { -1, 0, -1, -1, -1, -1, -1, -1, }
            );
            cMonths = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the weeks field.
     *
     * @return the period type
     */
    public static PeriodType weeks() {
        PeriodType type = cWeeks;
        if (type == null) {
            type = new PeriodType(
                "Weeks",
                new DurationFieldType[] { DurationFieldType.weeks() },
                new int[] { -1, -1, 0, -1, -1, -1, -1, -1, }
            );
            cWeeks = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the days field.
     *
     * @return the period type
     */
    public static PeriodType days() {
        PeriodType type = cDays;
        if (type == null) {
            type = new PeriodType(
                "Days",
                new DurationFieldType[] { DurationFieldType.days() },
                new int[] { -1, -1, -1, 0, -1, -1, -1, -1, }
            );
            cDays = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the hours field.
     *
     * @return the period type
     */
    public static PeriodType hours() {
        PeriodType type = cHours;
        if (type == null) {
            type = new PeriodType(
                "Hours",
                new DurationFieldType[] { DurationFieldType.hours() },
                new int[] { -1, -1, -1, -1, 0, -1, -1, -1, }
            );
            cHours = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the minutes field.
     *
     * @return the period type
     */
    public static PeriodType minutes() {
        PeriodType type = cMinutes;
        if (type == null) {
            type = new PeriodType(
                "Minutes",
                new DurationFieldType[] { DurationFieldType.minutes() },
                new int[] { -1, -1, -1, -1, -1, 0, -1, -1, }
            );
            cMinutes = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the seconds field.
     *
     * @return the period type
     */
    public static PeriodType seconds() {
        PeriodType type = cSeconds;
        if (type == null) {
            type = new PeriodType(
                "Seconds",
                new DurationFieldType[] { DurationFieldType.seconds() },
                new int[] { -1, -1, -1, -1, -1, -1, 0, -1, }
            );
            cSeconds = type;
        }
        return type;
    }

    /**
     * Gets a type that defines just the millis field.
     *
     * @return the period type
     */
    public static PeriodType millis() {
        PeriodType type = cMillis;
        if (type == null) {
            type = new PeriodType(
                "Millis",
                new DurationFieldType[] { DurationFieldType.millis() },
                new int[] { -1, -1, -1, -1, -1, -1, -1, 0, }
            );
            cMillis = type;
        }
        return type;
    }

    /**
     * Gets a period type that contains the duration types of the array.
     * <p>
     * Only the 8 standard duration field types are supported.
     *
     * @param types  the types to include in the array.
     * @return the period type
     * @since 1.1
     */
    public static synchronized PeriodType forFields(DurationFieldType[] types) {
        if (types == null || types.length == 0) {
            throw new IllegalArgumentException("Types array must not be null or empty");
        }
        for (int i = 0; i < types.length; i++) {
            if (types[i] == null) {
                throw new IllegalArgumentException("Types array must not contain null");
            }
        }
        Map cache = cTypes;
        if (cTypes.isEmpty()) {
            cache.put(standard(), standard());
            cache.put(yearMonthDayTime(), yearMonthDayTime());
            cache.put(yearMonthDay(), yearMonthDay());
            cache.put(yearWeekDayTime(), yearWeekDayTime());
            cache.put(yearWeekDay(), yearWeekDay());
            cache.put(yearDayTime(), yearDayTime());
            cache.put(yearDay(), yearDay());
            cache.put(dayTime(), dayTime());
            cache.put(time(), time());
            cache.put(years(), years());
            cache.put(months(), months());
            cache.put(weeks(), weeks());
            cache.put(days(), days());
            cache.put(hours(), hours());
            cache.put(minutes(), minutes());
            cache.put(seconds(), seconds());
            cache.put(millis(), millis());
        }
        PeriodType inPartType = new PeriodType(null, types, null);
        Object cached = cache.get(inPartType);
        if (cached instanceof PeriodType) {
            return (PeriodType) cached;
        }
        if (cached != null) {
            throw new IllegalArgumentException("PeriodType does not support fields: " + cached);
        }
        PeriodType type = standard();
        List list = new ArrayList(Arrays.asList(types));
        if (list.remove(DurationFieldType.years()) == false) {
            type = type.withYearsRemoved();
        }
        if (list.remove(DurationFieldType.months()) == false) {
            type = type.withMonthsRemoved();
        }
        if (list.remove(DurationFieldType.weeks()) == false) {
            type = type.withWeeksRemoved();
        }
        if (list.remove(DurationFieldType.days()) == false) {
            type = type.withDaysRemoved();
        }
        if (list.remove(DurationFieldType.hours()) == false) {
            type = type.withHoursRemoved();
        }
        if (list.remove(DurationFieldType.minutes()) == false) {
            type = type.withMinutesRemoved();
        }
        if (list.remove(DurationFieldType.seconds()) == false) {
            type = type.withSecondsRemoved();
        }
        if (list.remove(DurationFieldType.millis()) == false) {
            type = type.withMillisRemoved();
        }
        if (list.size() > 0) {
            cache.put(inPartType, list);
            throw new IllegalArgumentException("PeriodType does not support fields: " + list);
        }
        // recheck cache in case initial array order was wrong
        PeriodType checkPartType = new PeriodType(null, type.iTypes, null);
        PeriodType checkedType = (PeriodType) cache.get(checkPartType);
        if (checkedType != null) {
            cache.put(inPartType, checkedType);
            return checkedType;
        }
        cache.put(inPartType, type);
        return type;
    }

    //-----------------------------------------------------------------------    
    /** The name of the type */
    private final String iName;
    /** The array of types */
    private final DurationFieldType[] iTypes;
    /** The array of indices */
    private final int[] iIndices;

    /**
     * Constructor.
     *
     * @param name  the name
     * @param types  the types
     * @param indices  the indices
     */
    protected PeriodType(String name, DurationFieldType[] types, int[] indices) {
        super();
        iName = name;
        iTypes = types;
        iIndices = indices;
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the name of the period type.
     * 
     * @return the name
     */
    public String getName() {
        return iName;
    }

    /**
     * Gets the number of fields in the period type.
     * 
     * @return the number of fields
     */
    public int size() {
        return iTypes.length;
    }

    /**
     * Gets the field type by index.
     * 
     * @param index  the index to retrieve
     * @return the field type
     * @throws IndexOutOfBoundsException if the index is invalid
     */
    public DurationFieldType getFieldType(int index) {
        return iTypes[index];
    }

    /**
     * Checks whether the field specified is supported by this period.
     *
     * @param type  the type to check, may be null which returns false
     * @return true if the field is supported
     */
    public boolean isSupported(DurationFieldType type) {
        return (indexOf(type) >= 0);
    }

    /**
     * Gets the index of the field in this period.
     *
     * @param type  the type to check, may be null which returns -1
     * @return the index of -1 if not supported
     */
    public int indexOf(DurationFieldType type) {
        for (int i = 0, isize = size(); i < isize; i++) {
            if (iTypes[i] == type) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Gets a debugging to string.
     * 
     * @return a string
     */
    public String toString() {
        return "PeriodType[" + getName() + "]";
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the indexed field part of the period.
     * 
     * @param period  the period to query
     * @param index  the index to use
     * @return the value of the field, zero if unsupported
     */
    int getIndexedField(ReadablePeriod period, int index) {
        int realIndex = iIndices[index];
        return (realIndex == -1 ? 0 : period.getValue(realIndex));
    }

    /**
     * Sets the indexed field part of the period.
     * 
     * @param period  the period to query
     * @param index  the index to use
     * @param values  the array to populate
     * @param newValue  the value to set
     * @throws UnsupportedOperationException if not supported
     */
    boolean setIndexedField(ReadablePeriod period, int index, int[] values, int newValue) {
        int realIndex = iIndices[index];
        if (realIndex == -1) {
            throw new UnsupportedOperationException("Field is not supported");
        }
        values[realIndex] = newValue;
        return true;
    }

    /**
     * Adds to the indexed field part of the period.
     * 
     * @param period  the period to query
     * @param index  the index to use
     * @param values  the array to populate
     * @param valueToAdd  the value to add
     * @return true if the array is updated
     * @throws UnsupportedOperationException if not supported
     */
    boolean addIndexedField(ReadablePeriod period, int index, int[] values, int valueToAdd) {
        if (valueToAdd == 0) {
            return false;
        }
        int realIndex = iIndices[index];
        if (realIndex == -1) {
            throw new UnsupportedOperationException("Field is not supported");
        }
        values[realIndex] = FieldUtils.safeAdd(values[realIndex], valueToAdd);
        return true;
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a version of this PeriodType instance that does not support years.
     * 
     * @return a new period type that supports the original set of fields except years
     */
    public PeriodType withYearsRemoved() {
        return withFieldRemoved(0, "NoYears");
    }

    /**
     * Returns a version of this PeriodType instance that does not support months.
     * 
     * @return a new period type that supports the original set of fields except months
     */
    public PeriodType withMonthsRemoved() {
        return withFieldRemoved(1, "NoMonths");
    }

    /**
     * Returns a version of this PeriodType instance that does not support weeks.
     * 
     * @return a new period type that supports the original set of fields except weeks
     */
    public PeriodType withWeeksRemoved() {
        return withFieldRemoved(2, "NoWeeks");
    }

    /**
     * Returns a version of this PeriodType instance that does not support days.
     * 
     * @return a new period type that supports the original set of fields except days
     */
    public PeriodType withDaysRemoved() {
        return withFieldRemoved(3, "NoDays");
    }

    /**
     * Returns a version of this PeriodType instance that does not support hours.
     * 
     * @return a new period type that supports the original set of fields except hours
     */
    public PeriodType withHoursRemoved() {
        return withFieldRemoved(4, "NoHours");
    }

    /**
     * Returns a version of this PeriodType instance that does not support minutes.
     * 
     * @return a new period type that supports the original set of fields except minutes
     */
    public PeriodType withMinutesRemoved() {
        return withFieldRemoved(5, "NoMinutes");
    }

    /**
     * Returns a version of this PeriodType instance that does not support seconds.
     * 
     * @return a new period type that supports the original set of fields except seconds
     */
    public PeriodType withSecondsRemoved() {
        return withFieldRemoved(6, "NoSeconds");
    }

    /**
     * Returns a version of this PeriodType instance that does not support milliseconds.
     * 
     * @return a new period type that supports the original set of fields except milliseconds
     */
    public PeriodType withMillisRemoved() {
        return withFieldRemoved(7, "NoMillis");
    }

    /**
     * Removes the field specified by indices index.
     * 
     * @param indicesIndex  the index to remove
     * @param name  the name addition
     * @return the new type
     */
    private PeriodType withFieldRemoved(int indicesIndex, String name) {
        int fieldIndex = iIndices[indicesIndex];
        if (fieldIndex == -1) {
            return this;
        }
        
        DurationFieldType[] types = new DurationFieldType[size() - 1];
        for (int i = 0; i < iTypes.length; i++) {
            if (i < fieldIndex) {
                types[i] = iTypes[i];
            } else if (i > fieldIndex) {
                types[i - 1] = iTypes[i];
            }
        }
        
        int[] indices = new int[8];
        for (int i = 0; i < indices.length; i++) {
            if (i < indicesIndex) {
                indices[i] = iIndices[i];
            } else if (i > indicesIndex) {
                indices[i] = (iIndices[i] == -1 ? -1 : iIndices[i] - 1);
            } else {
                indices[i] = -1;
            }
        }
        return new PeriodType(getName() + name, types, indices);
    }

    //-----------------------------------------------------------------------
    /**
     * Compares this type to another object.
     * To be equal, the object must be a PeriodType with the same set of fields.
     * 
     * @param obj  the object to compare to
     * @return true if equal
     */
    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj instanceof PeriodType == false) {
            return false;
        }
        PeriodType other = (PeriodType) obj;
        return (Arrays.equals(iTypes, other.iTypes));
    }

    /**
     * Returns a hashcode based on the field types.
     * 
     * @return a suitable hashcode
     */
    public int hashCode() {
        int hash = 0;
        for (int i = 0; i < iTypes.length; i++) {
            hash += iTypes[i].hashCode();
        }
        return hash;
    }

}
