/*
 * SPDX-License-Identifier: Apache-2.0
 *
 * The OpenSearch Contributors require contributions made to
 * this file be licensed under the Apache-2.0 license or a
 * compatible open source license.
 */

/*
 * Licensed to Elasticsearch under one or more contributor
 * license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright
 * ownership. Elasticsearch licenses this file to you 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.
 */

/*
 * Modifications Copyright OpenSearch Contributors. See
 * GitHub history for details.
 */

package org.opensearch.search.aggregations.bucket.histogram;

import org.opensearch.LegacyESVersion;
import org.opensearch.common.ParseField;
import org.opensearch.common.Rounding;
import org.opensearch.common.Rounding.DateTimeUnit;
import org.opensearch.common.Strings;
import org.opensearch.common.io.stream.StreamInput;
import org.opensearch.common.io.stream.StreamOutput;
import org.opensearch.common.io.stream.Writeable;
import org.opensearch.common.logging.DeprecationLogger;
import org.opensearch.common.unit.TimeValue;
import org.opensearch.common.xcontent.ObjectParser;
import org.opensearch.common.xcontent.ToXContentFragment;
import org.opensearch.common.xcontent.XContentBuilder;
import org.opensearch.common.xcontent.XContentParser;

import java.io.IOException;
import java.time.ZoneId;
import java.util.Locale;
import java.util.Objects;

/**
 * A class that handles all the parsing, bwc and deprecations surrounding date histogram intervals.
 *
 * - Provides parser helpers for the deprecated interval/dateHistogramInterval parameters.
 * - Provides parser helpers for the new calendar/fixed interval parameters
 * - Can read old intervals from a stream and convert to new intervals
 * - Can write new intervals to old format when streaming out
 * - Provides a variety of helper methods to interpret the intervals as different types, depending on caller's need
 *
 * After the deprecated parameters are removed, this class can be simplified greatly.  The legacy options
 * will be removed, and the mutual-exclusion checks can be done in the setters directly removing the need
 * for the enum and the complicated "state machine" logic
 *
 * @opensearch.internal
 */
public class DateIntervalWrapper implements ToXContentFragment, Writeable {
    private static final DeprecationLogger DEPRECATION_LOGGER = DeprecationLogger.getLogger(DateHistogramAggregationBuilder.class);
    private static final String DEPRECATION_TEXT = "[interval] on [date_histogram] is deprecated, use [fixed_interval] or "
        + "[calendar_interval] in the future.";

    private static final ParseField FIXED_INTERVAL_FIELD = new ParseField("fixed_interval");
    private static final ParseField CALENDAR_INTERVAL_FIELD = new ParseField("calendar_interval");

    /**
     * The type of interval
     *
     * @opensearch.internal
     */
    public enum IntervalTypeEnum implements Writeable {
        NONE,
        FIXED,
        CALENDAR,
        LEGACY_INTERVAL,
        LEGACY_DATE_HISTO;

        public static IntervalTypeEnum fromString(String name) {
            return valueOf(name.trim().toUpperCase(Locale.ROOT));
        }

        public static IntervalTypeEnum fromStream(StreamInput in) throws IOException {
            return in.readEnum(IntervalTypeEnum.class);
        }

        @Override
        public void writeTo(StreamOutput out) throws IOException {
            out.writeEnum(this);
        }

        public String value() {
            return name().toLowerCase(Locale.ROOT);
        }
    }

    private DateHistogramInterval dateHistogramInterval;
    private IntervalTypeEnum intervalType = IntervalTypeEnum.NONE;

    public static <T extends DateIntervalConsumer> void declareIntervalFields(ObjectParser<T, String> parser) {

        // NOTE: this field is deprecated and will be removed
        parser.declareField((wrapper, interval) -> {
            if (interval instanceof Long) {
                wrapper.interval((long) interval);
            } else {
                wrapper.dateHistogramInterval((DateHistogramInterval) interval);
            }
        }, p -> {
            if (p.currentToken() == XContentParser.Token.VALUE_NUMBER) {
                return p.longValue();
            } else {
                return new DateHistogramInterval(p.text());
            }
        }, Histogram.INTERVAL_FIELD, ObjectParser.ValueType.LONG);

        parser.declareField(
            DateIntervalConsumer::calendarInterval,
            p -> new DateHistogramInterval(p.text()),
            CALENDAR_INTERVAL_FIELD,
            ObjectParser.ValueType.STRING
        );

        parser.declareField(
            DateIntervalConsumer::fixedInterval,
            p -> new DateHistogramInterval(p.text()),
            FIXED_INTERVAL_FIELD,
            ObjectParser.ValueType.STRING
        );
    }

    public DateIntervalWrapper() {}

    public DateIntervalWrapper(StreamInput in) throws IOException {
        if (in.getVersion().before(LegacyESVersion.V_7_2_0)) {
            long interval = in.readLong();
            DateHistogramInterval histoInterval = in.readOptionalWriteable(DateHistogramInterval::new);

            if (histoInterval != null) {
                dateHistogramInterval = histoInterval;
                intervalType = IntervalTypeEnum.LEGACY_DATE_HISTO;
            } else {
                dateHistogramInterval = new DateHistogramInterval(interval + "ms");
                intervalType = IntervalTypeEnum.LEGACY_INTERVAL;
            }
        } else {
            dateHistogramInterval = in.readOptionalWriteable(DateHistogramInterval::new);
            intervalType = IntervalTypeEnum.fromStream(in);
        }
    }

    public IntervalTypeEnum getIntervalType() {
        return intervalType;
    }

    /** Get the current interval in milliseconds that is set on this builder. */
    @Deprecated
    public long interval() {
        DEPRECATION_LOGGER.deprecate("date-histogram-interval", DEPRECATION_TEXT);
        if (intervalType.equals(IntervalTypeEnum.LEGACY_INTERVAL)) {
            return TimeValue.parseTimeValue(dateHistogramInterval.toString(), "interval").getMillis();
        }
        return 0;
    }

    /** Set the interval on this builder, and return the builder so that calls can be chained.
     *  If both {@link #interval()} and {@link #dateHistogramInterval()} are set, then the
     *  {@link #dateHistogramInterval()} wins.
     *
     *  @deprecated use {@link DateHistogramAggregationBuilder#fixedInterval(DateHistogramInterval)}
     *              or {@link DateHistogramAggregationBuilder#calendarInterval(DateHistogramInterval)} instead
     *  @since 7.2.0
     */
    @Deprecated
    public void interval(long interval) {
        if (interval < 1) {
            throw new IllegalArgumentException("[interval] must be 1 or greater for aggregation [date_histogram]");
        }
        setIntervalType(IntervalTypeEnum.LEGACY_INTERVAL);
        DEPRECATION_LOGGER.deprecate("date-histogram-interval", DEPRECATION_TEXT);
        this.dateHistogramInterval = new DateHistogramInterval(interval + "ms");
    }

    /** Get the current date interval that is set on this builder. */
    @Deprecated
    public DateHistogramInterval dateHistogramInterval() {
        DEPRECATION_LOGGER.deprecate("date-histogram-interval", DEPRECATION_TEXT);
        if (intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO)) {
            return dateHistogramInterval;
        }
        return null;
    }

    /** Set the interval on this builder, and return the builder so that calls can be chained.
     *  If both {@link #interval()} and {@link #dateHistogramInterval()} are set, then the
     *  {@link #dateHistogramInterval()} wins.
     *
     *  @deprecated use {@link DateIntervalWrapper#fixedInterval(DateHistogramInterval)}
     *              or {@link DateIntervalWrapper#calendarInterval(DateHistogramInterval)} instead
     *  @since 7.2.0
     */
    @Deprecated
    public void dateHistogramInterval(DateHistogramInterval dateHistogramInterval) {
        if (dateHistogramInterval == null || Strings.isNullOrEmpty(dateHistogramInterval.toString())) {
            throw new IllegalArgumentException("[dateHistogramInterval] must not be null: [date_histogram]");
        }
        setIntervalType(IntervalTypeEnum.LEGACY_DATE_HISTO);
        DEPRECATION_LOGGER.deprecate("date-histogram-interval", DEPRECATION_TEXT);
        this.dateHistogramInterval = dateHistogramInterval;
    }

    /**
     * Returns the interval as a calendar interval.  Throws an exception if the value cannot be converted
     * into a calendar interval
     */
    public DateHistogramInterval getAsCalendarInterval() {
        if (intervalType.equals(IntervalTypeEnum.CALENDAR) || tryIntervalAsCalendarUnit() != null) {
            return dateHistogramInterval;
        }
        throw new IllegalStateException("Cannot convert [" + intervalType.toString() + "] interval type into calendar interval");
    }

    /**
     * Sets the interval of the DateHistogram using calendar units (`1d`, `1w`, `1M`, etc).  These units
     * are calendar-aware, meaning they respect leap additions, variable days per month, etc.
     *
     * This is mutually exclusive with {@link DateIntervalWrapper#fixedInterval(DateHistogramInterval)}
     *
     * @param interval The fixed interval to use
     */
    public void calendarInterval(DateHistogramInterval interval) {
        if (interval == null || Strings.isNullOrEmpty(interval.toString())) {
            throw new IllegalArgumentException("[interval] must not be null: [date_histogram]");
        }
        if (DateHistogramAggregationBuilder.DATE_FIELD_UNITS.get(interval.toString()) == null) {
            throw new IllegalArgumentException("The supplied interval [" + interval + "] could not be parsed " + "as a calendar interval.");
        }
        setIntervalType(IntervalTypeEnum.CALENDAR);
        this.dateHistogramInterval = interval;
    }

    /**
     * Returns the interval as a Fixed interval. Throws an exception if the value cannot be converted
     * into a fixed interval
     */
    public DateHistogramInterval getAsFixedInterval() {
        if (intervalType.equals(IntervalTypeEnum.FIXED) || tryIntervalAsFixedUnit() != null) {
            return dateHistogramInterval;
        }
        throw new IllegalStateException("Cannot convert [" + intervalType.toString() + "] interval type into fixed interval");
    }

    /**
     * Sets the interval of the DateHistogram using fixed units (`1ms`, `1s`, `10m`, `4h`, etc).  These are
     * not calendar aware and are simply multiples of fixed, SI units.
     *
     * This is mutually exclusive with {@link DateIntervalWrapper#calendarInterval(DateHistogramInterval)}
     *
     * @param interval The fixed interval to use
     */
    public void fixedInterval(DateHistogramInterval interval) {
        if (interval == null || Strings.isNullOrEmpty(interval.toString())) {
            throw new IllegalArgumentException("[interval] must not be null: [date_histogram]");
        }
        setIntervalType(IntervalTypeEnum.FIXED);
        // Parse to make sure it is a valid fixed too
        TimeValue.parseTimeValue(interval.toString(), DateHistogramAggregationBuilder.NAME + ".fixedInterval");
        this.dateHistogramInterval = interval;
    }

    /** Return the interval as a date time unit if applicable, regardless of how it was configured. If this returns
     *  {@code null} then it means that the interval is expressed as a fixed
     *  {@link TimeValue} and may be accessed via {@link #tryIntervalAsFixedUnit()}. */
    DateTimeUnit tryIntervalAsCalendarUnit() {
        if (intervalType.equals(IntervalTypeEnum.CALENDAR) || intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO)) {
            return DateHistogramAggregationBuilder.DATE_FIELD_UNITS.get(dateHistogramInterval.toString());
        }
        return null;
    }

    /**
     * Get the interval as a {@link TimeValue}, regardless of how it was configured. Returns null if
     * the interval cannot be parsed as a fixed time.
     */
    TimeValue tryIntervalAsFixedUnit() {
        if (dateHistogramInterval == null || Strings.isNullOrEmpty(dateHistogramInterval.toString())) {
            return null;
        }
        try {
            return TimeValue.parseTimeValue(dateHistogramInterval.toString(), null, getClass().getSimpleName() + ".interval");
        } catch (IllegalArgumentException e) {
            return null;
        }
    }

    public Rounding createRounding(ZoneId timeZone, long offset) {
        Rounding.Builder tzRoundingBuilder;
        if (isEmpty()) {
            throw new IllegalArgumentException("Invalid interval specified, must be non-null and non-empty");
        }
        DateIntervalWrapper.IntervalTypeEnum intervalType = getIntervalType();
        if (intervalType.equals(DateIntervalWrapper.IntervalTypeEnum.FIXED)) {
            tzRoundingBuilder = Rounding.builder(tryIntervalAsFixedUnit());
        } else if (intervalType.equals(DateIntervalWrapper.IntervalTypeEnum.CALENDAR)) {
            tzRoundingBuilder = Rounding.builder(tryIntervalAsCalendarUnit());
        } else {
            // We're not sure what the interval was originally (legacy) so use old behavior of assuming
            // calendar first, then fixed. Required because fixed/cal overlap in places ("1h")
            DateTimeUnit calInterval = tryIntervalAsCalendarUnit();
            TimeValue fixedInterval = tryIntervalAsFixedUnit();
            if (calInterval != null) {
                tzRoundingBuilder = Rounding.builder(calInterval);
            } else if (fixedInterval != null) {
                tzRoundingBuilder = Rounding.builder(fixedInterval);
            } else {
                // If we get here we have exhausted our options and are not able to parse this interval
                throw new IllegalArgumentException("Unable to parse interval [" + dateHistogramInterval + "]");
            }
        }
        if (timeZone != null) {
            tzRoundingBuilder.timeZone(timeZone);
        }
        tzRoundingBuilder.offset(offset);
        return tzRoundingBuilder.build();
    }

    private void setIntervalType(IntervalTypeEnum type) {
        // If we're the same or have no existing type, just use the provided type
        if (intervalType.equals(IntervalTypeEnum.NONE) || type.equals(intervalType)) {
            intervalType = type;
            return;
        }

        // interval() method
        switch (type) {
            case LEGACY_INTERVAL:
                if (intervalType.equals(IntervalTypeEnum.CALENDAR) || intervalType.equals(IntervalTypeEnum.FIXED)) {
                    throw new IllegalArgumentException(
                        "Cannot use [interval] with [fixed_interval] or [calendar_interval] " + "configuration options."
                    );
                }

                // dateHistogramInterval() takes precedence over interval()
                if (intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO) == false) {
                    intervalType = IntervalTypeEnum.LEGACY_INTERVAL;
                }
                break;

            case LEGACY_DATE_HISTO:
                if (intervalType.equals(IntervalTypeEnum.CALENDAR) || intervalType.equals(IntervalTypeEnum.FIXED)) {
                    throw new IllegalArgumentException(
                        "Cannot use [interval] with [fixed_interval] or [calendar_interval] " + "configuration options."
                    );
                }

                // dateHistogramInterval() takes precedence over interval()
                intervalType = IntervalTypeEnum.LEGACY_DATE_HISTO;
                break;

            case FIXED:
                if (intervalType.equals(IntervalTypeEnum.LEGACY_INTERVAL) || intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO)) {
                    throw new IllegalArgumentException("Cannot use [fixed_interval] with [interval] " + "configuration option.");
                }
                if (intervalType.equals(IntervalTypeEnum.CALENDAR)) {
                    throw new IllegalArgumentException("Cannot use [fixed_interval] with [calendar_interval] " + "configuration option.");
                }
                intervalType = IntervalTypeEnum.FIXED;
                break;

            case CALENDAR:
                if (intervalType.equals(IntervalTypeEnum.LEGACY_INTERVAL) || intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO)) {
                    throw new IllegalArgumentException("Cannot use [calendar_interval] with [interval] " + "configuration option.");
                }
                if (intervalType.equals(IntervalTypeEnum.FIXED)) {
                    throw new IllegalArgumentException("Cannot use [calendar_interval] with [fixed_interval] " + "configuration option.");
                }
                intervalType = IntervalTypeEnum.CALENDAR;
                break;

            default:
                throw new IllegalStateException("Unknown interval type.");
        }
    }

    public boolean isEmpty() {
        if (intervalType.equals(IntervalTypeEnum.NONE)) {
            return true;
        }
        return dateHistogramInterval == null || Strings.isNullOrEmpty(dateHistogramInterval.toString());
    }

    @Override
    public void writeTo(StreamOutput out) throws IOException {
        if (out.getVersion().before(LegacyESVersion.V_7_2_0)) {
            if (intervalType.equals(IntervalTypeEnum.LEGACY_INTERVAL)) {
                out.writeLong(
                    TimeValue.parseTimeValue(dateHistogramInterval.toString(), DateHistogramAggregationBuilder.NAME + ".innerWriteTo")
                        .getMillis()
                );
            } else {
                out.writeLong(0L);
            }
            out.writeOptionalWriteable(dateHistogramInterval);
        } else {
            out.writeOptionalWriteable(dateHistogramInterval);
            intervalType.writeTo(out);
        }
    }

    @Override
    public XContentBuilder toXContent(XContentBuilder builder, Params params) throws IOException {
        if (intervalType.equals(IntervalTypeEnum.LEGACY_DATE_HISTO) || intervalType.equals(IntervalTypeEnum.LEGACY_INTERVAL)) {
            builder.field(Histogram.INTERVAL_FIELD.getPreferredName(), dateHistogramInterval.toString());
        } else if (intervalType.equals(IntervalTypeEnum.FIXED)) {
            builder.field(FIXED_INTERVAL_FIELD.getPreferredName(), dateHistogramInterval.toString());
        } else if (intervalType.equals(IntervalTypeEnum.CALENDAR)) {
            builder.field(CALENDAR_INTERVAL_FIELD.getPreferredName(), dateHistogramInterval.toString());
        }
        return builder;
    }

    @Override
    public boolean equals(Object other) {
        if (this == other) {
            return true;
        }
        if (other == null || getClass() != other.getClass()) {
            return false;
        }

        final DateIntervalWrapper that = (DateIntervalWrapper) other;
        if (tryIntervalAsCalendarUnit() != null && that.tryIntervalAsCalendarUnit() == null) {
            return false;
        }
        if (tryIntervalAsCalendarUnit() == null && that.tryIntervalAsCalendarUnit() != null) {
            return false;
        }
        return Objects.equals(this.dateHistogramInterval, that.dateHistogramInterval);
    }

    @Override
    public int hashCode() {
        boolean isCalendar = tryIntervalAsCalendarUnit() != null;
        return Objects.hash(dateHistogramInterval, isCalendar);
    }
}
