/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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.
 */
package com.datastax.oss.driver.api.core.data;

import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import com.datastax.oss.driver.shaded.guava.common.base.Objects;
import com.datastax.oss.driver.shaded.guava.common.base.Preconditions;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
import edu.umd.cs.findbugs.annotations.NonNull;
import java.io.Serializable;
import java.time.Duration;
import java.time.Period;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAmount;
import java.time.temporal.TemporalUnit;
import java.time.temporal.UnsupportedTemporalTypeException;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.jcip.annotations.Immutable;

/**
 * A duration, as defined in CQL.
 *
 * <p>It stores months, days, and seconds separately due to the fact that the number of days in a
 * month varies, and a day can have 23 or 25 hours if a daylight saving is involved. As such, this
 * type differs from {@link java.time.Duration} (which only represents an amount between two points
 * in time, regardless of the calendar).
 */
@Immutable
public final class CqlDuration implements TemporalAmount, Serializable {

  private static final long serialVersionUID = 1L;

  @VisibleForTesting static final long NANOS_PER_MICRO = 1000L;
  @VisibleForTesting static final long NANOS_PER_MILLI = 1000 * NANOS_PER_MICRO;
  @VisibleForTesting static final long NANOS_PER_SECOND = 1000 * NANOS_PER_MILLI;
  @VisibleForTesting static final long NANOS_PER_MINUTE = 60 * NANOS_PER_SECOND;
  @VisibleForTesting static final long NANOS_PER_HOUR = 60 * NANOS_PER_MINUTE;
  @VisibleForTesting static final int DAYS_PER_WEEK = 7;
  @VisibleForTesting static final int MONTHS_PER_YEAR = 12;

  /** The Regexp used to parse the duration provided as String. */
  private static final Pattern STANDARD_PATTERN =
      Pattern.compile(
          "\\G(\\d+)(y|Y|mo|MO|mO|Mo|w|W|d|D|h|H|s|S|ms|MS|mS|Ms|us|US|uS|Us|µs|µS|ns|NS|nS|Ns|m|M)");

  /**
   * The Regexp used to parse the duration when provided in the ISO 8601 format with designators.
   */
  private static final Pattern ISO8601_PATTERN =
      Pattern.compile("P((\\d+)Y)?((\\d+)M)?((\\d+)D)?(T((\\d+)H)?((\\d+)M)?((\\d+)S)?)?");

  /**
   * The Regexp used to parse the duration when provided in the ISO 8601 format with designators.
   */
  private static final Pattern ISO8601_WEEK_PATTERN = Pattern.compile("P(\\d+)W");

  /** The Regexp used to parse the duration when provided in the ISO 8601 alternative format. */
  private static final Pattern ISO8601_ALTERNATIVE_PATTERN =
      Pattern.compile("P(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})");

  private static final ImmutableList<TemporalUnit> TEMPORAL_UNITS =
      ImmutableList.of(ChronoUnit.MONTHS, ChronoUnit.DAYS, ChronoUnit.NANOS);

  /** @serial */
  private final int months;
  /** @serial */
  private final int days;
  /** @serial */
  private final long nanoseconds;

  private CqlDuration(int months, int days, long nanoseconds) {
    // Makes sure that all the values are negative if one of them is
    if ((months < 0 || days < 0 || nanoseconds < 0)
        && (months > 0 || days > 0 || nanoseconds > 0)) {
      throw new IllegalArgumentException(
          String.format(
              "All values must be either negative or positive, got %d months, %d days, %d nanoseconds",
              months, days, nanoseconds));
    }
    this.months = months;
    this.days = days;
    this.nanoseconds = nanoseconds;
  }

  /**
   * Creates a duration with the given number of months, days and nanoseconds.
   *
   * <p>A duration can be negative. In this case, all the non zero values must be negative.
   *
   * @param months the number of months
   * @param days the number of days
   * @param nanoseconds the number of nanoseconds
   * @throws IllegalArgumentException if the values are not all negative or all positive
   */
  public static CqlDuration newInstance(int months, int days, long nanoseconds) {
    return new CqlDuration(months, days, nanoseconds);
  }

  /**
   * Converts a <code>String</code> into a duration.
   *
   * <p>The accepted formats are:
   *
   * <ul>
   *   <li>multiple digits followed by a time unit like: 12h30m where the time unit can be:
   *       <ul>
   *         <li>{@code y}: years
   *         <li>{@code mo}: months
   *         <li>{@code w}: weeks
   *         <li>{@code d}: days
   *         <li>{@code h}: hours
   *         <li>{@code m}: minutes
   *         <li>{@code s}: seconds
   *         <li>{@code ms}: milliseconds
   *         <li>{@code us} or {@code µs}: microseconds
   *         <li>{@code ns}: nanoseconds
   *       </ul>
   *   <li>ISO 8601 format: P[n]Y[n]M[n]DT[n]H[n]M[n]S or P[n]W
   *   <li>ISO 8601 alternative format: P[YYYY]-[MM]-[DD]T[hh]:[mm]:[ss]
   * </ul>
   *
   * @param input the <code>String</code> to convert
   */
  public static CqlDuration from(@NonNull String input) {
    boolean isNegative = input.startsWith("-");
    String source = isNegative ? input.substring(1) : input;

    if (source.startsWith("P")) {
      if (source.endsWith("W")) {
        return parseIso8601WeekFormat(isNegative, source);
      }
      if (source.contains("-")) {
        return parseIso8601AlternativeFormat(isNegative, source);
      }
      return parseIso8601Format(isNegative, source);
    }
    return parseStandardFormat(isNegative, source);
  }

  private static CqlDuration parseIso8601Format(boolean isNegative, @NonNull String source) {
    Matcher matcher = ISO8601_PATTERN.matcher(source);
    if (!matcher.matches())
      throw new IllegalArgumentException(
          String.format("Unable to convert '%s' to a duration", source));

    Builder builder = new Builder(isNegative);
    if (matcher.group(1) != null) {
      builder.addYears(groupAsLong(matcher, 2));
    }
    if (matcher.group(3) != null) {
      builder.addMonths(groupAsLong(matcher, 4));
    }
    if (matcher.group(5) != null) {
      builder.addDays(groupAsLong(matcher, 6));
    }
    // Checks if the String contains time information
    if (matcher.group(7) != null) {
      if (matcher.group(8) != null) {
        builder.addHours(groupAsLong(matcher, 9));
      }
      if (matcher.group(10) != null) {
        builder.addMinutes(groupAsLong(matcher, 11));
      }
      if (matcher.group(12) != null) {
        builder.addSeconds(groupAsLong(matcher, 13));
      }
    }
    return builder.build();
  }

  private static CqlDuration parseIso8601AlternativeFormat(
      boolean isNegative, @NonNull String source) {
    Matcher matcher = ISO8601_ALTERNATIVE_PATTERN.matcher(source);
    if (!matcher.matches()) {
      throw new IllegalArgumentException(
          String.format("Unable to convert '%s' to a duration", source));
    }
    return new Builder(isNegative)
        .addYears(groupAsLong(matcher, 1))
        .addMonths(groupAsLong(matcher, 2))
        .addDays(groupAsLong(matcher, 3))
        .addHours(groupAsLong(matcher, 4))
        .addMinutes(groupAsLong(matcher, 5))
        .addSeconds(groupAsLong(matcher, 6))
        .build();
  }

  private static CqlDuration parseIso8601WeekFormat(boolean isNegative, @NonNull String source) {
    Matcher matcher = ISO8601_WEEK_PATTERN.matcher(source);
    if (!matcher.matches()) {
      throw new IllegalArgumentException(
          String.format("Unable to convert '%s' to a duration", source));
    }
    return new Builder(isNegative).addWeeks(groupAsLong(matcher, 1)).build();
  }

  private static CqlDuration parseStandardFormat(boolean isNegative, @NonNull String source) {
    Matcher matcher = STANDARD_PATTERN.matcher(source);
    if (!matcher.find()) {
      throw new IllegalArgumentException(
          String.format("Unable to convert '%s' to a duration", source));
    }
    Builder builder = new Builder(isNegative);
    boolean done;

    do {
      long number = groupAsLong(matcher, 1);
      String symbol = matcher.group(2);
      add(builder, number, symbol);
      done = matcher.end() == source.length();
    } while (matcher.find());

    if (!done) {
      throw new IllegalArgumentException(
          String.format("Unable to convert '%s' to a duration", source));
    }
    return builder.build();
  }

  private static long groupAsLong(@NonNull Matcher matcher, int group) {
    return Long.parseLong(matcher.group(group));
  }

  private static Builder add(@NonNull Builder builder, long number, @NonNull String symbol) {
    String s = symbol.toLowerCase(Locale.ROOT);
    if (s.equals("y")) {
      return builder.addYears(number);
    } else if (s.equals("mo")) {
      return builder.addMonths(number);
    } else if (s.equals("w")) {
      return builder.addWeeks(number);
    } else if (s.equals("d")) {
      return builder.addDays(number);
    } else if (s.equals("h")) {
      return builder.addHours(number);
    } else if (s.equals("m")) {
      return builder.addMinutes(number);
    } else if (s.equals("s")) {
      return builder.addSeconds(number);
    } else if (s.equals("ms")) {
      return builder.addMillis(number);
    } else if (s.equals("us") || s.equals("µs")) {
      return builder.addMicros(number);
    } else if (s.equals("ns")) {
      return builder.addNanos(number);
    }
    throw new IllegalArgumentException(String.format("Unknown duration symbol '%s'", symbol));
  }

  /**
   * Appends the result of the division to the specified builder if the dividend is not zero.
   *
   * @param builder the builder to append to
   * @param dividend the dividend
   * @param divisor the divisor
   * @param unit the time unit to append after the result of the division
   * @return the remainder of the division
   */
  private static long append(
      @NonNull StringBuilder builder, long dividend, long divisor, @NonNull String unit) {
    if (dividend == 0 || dividend < divisor) {
      return dividend;
    }
    builder.append(dividend / divisor).append(unit);
    return dividend % divisor;
  }

  /**
   * Returns the number of months in this duration.
   *
   * @return the number of months in this duration.
   */
  public int getMonths() {
    return months;
  }

  /**
   * Returns the number of days in this duration.
   *
   * @return the number of days in this duration.
   */
  public int getDays() {
    return days;
  }

  /**
   * Returns the number of nanoseconds in this duration.
   *
   * @return the number of months in this duration.
   */
  public long getNanoseconds() {
    return nanoseconds;
  }

  /**
   * {@inheritDoc}
   *
   * <p>This implementation converts the months and days components to a {@link Period}, and the
   * nanosecond component to a {@link Duration}, and adds those two amounts to the temporal object.
   * Therefore the chronology of the temporal must be either the ISO chronology or null.
   *
   * @see Period#addTo(Temporal)
   * @see Duration#addTo(Temporal)
   */
  @Override
  public Temporal addTo(Temporal temporal) {
    return temporal.plus(Period.of(0, months, days)).plus(Duration.ofNanos(nanoseconds));
  }

  /**
   * {@inheritDoc}
   *
   * <p>This implementation converts the months and days components to a {@link Period}, and the
   * nanosecond component to a {@link Duration}, and subtracts those two amounts to the temporal
   * object. Therefore the chronology of the temporal must be either the ISO chronology or null.
   *
   * @see Period#subtractFrom(Temporal)
   * @see Duration#subtractFrom(Temporal)
   */
  @Override
  public Temporal subtractFrom(Temporal temporal) {
    return temporal.minus(Period.of(0, months, days)).minus(Duration.ofNanos(nanoseconds));
  }

  @Override
  public long get(TemporalUnit unit) {
    if (unit == ChronoUnit.MONTHS) {
      return months;
    } else if (unit == ChronoUnit.DAYS) {
      return days;
    } else if (unit == ChronoUnit.NANOS) {
      return nanoseconds;
    } else {
      throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
    }
  }

  @Override
  public List<TemporalUnit> getUnits() {
    return TEMPORAL_UNITS;
  }

  @Override
  public boolean equals(Object other) {
    if (other == this) {
      return true;
    } else if (other instanceof CqlDuration) {
      CqlDuration that = (CqlDuration) other;
      return this.days == that.days
          && this.months == that.months
          && this.nanoseconds == that.nanoseconds;
    } else {
      return false;
    }
  }

  @Override
  public int hashCode() {
    return Objects.hashCode(days, months, nanoseconds);
  }

  @Override
  public String toString() {
    StringBuilder builder = new StringBuilder();

    if (months < 0 || days < 0 || nanoseconds < 0) {
      builder.append('-');
    }
    long remainder = append(builder, Math.abs(months), MONTHS_PER_YEAR, "y");
    append(builder, remainder, 1, "mo");

    append(builder, Math.abs(days), 1, "d");

    if (nanoseconds != 0) {
      remainder = append(builder, Math.abs(nanoseconds), NANOS_PER_HOUR, "h");
      remainder = append(builder, remainder, NANOS_PER_MINUTE, "m");
      remainder = append(builder, remainder, NANOS_PER_SECOND, "s");
      remainder = append(builder, remainder, NANOS_PER_MILLI, "ms");
      remainder = append(builder, remainder, NANOS_PER_MICRO, "us");
      append(builder, remainder, 1, "ns");
    }
    return builder.toString();
  }

  private static class Builder {
    private final boolean isNegative;
    private int months;
    private int days;
    private long nanoseconds;

    /** We need to make sure that the values for each units are provided in order. */
    private int currentUnitIndex;

    public Builder(boolean isNegative) {
      this.isNegative = isNegative;
    }

    /**
     * Adds the specified amount of years.
     *
     * @param numberOfYears the number of years to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addYears(long numberOfYears) {
      validateOrder(1);
      validateMonths(numberOfYears, MONTHS_PER_YEAR);
      // Cast to avoid http://errorprone.info/bugpattern/NarrowingCompoundAssignment
      // We could also change the method to accept an int, but keeping long allows us to keep the
      // calling code generic.
      months += (int) numberOfYears * MONTHS_PER_YEAR;
      return this;
    }

    /**
     * Adds the specified amount of months.
     *
     * @param numberOfMonths the number of months to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addMonths(long numberOfMonths) {
      validateOrder(2);
      validateMonths(numberOfMonths, 1);
      months += (int) numberOfMonths;
      return this;
    }

    /**
     * Adds the specified amount of weeks.
     *
     * @param numberOfWeeks the number of weeks to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addWeeks(long numberOfWeeks) {
      validateOrder(3);
      validateDays(numberOfWeeks, DAYS_PER_WEEK);
      days += (int) numberOfWeeks * DAYS_PER_WEEK;
      return this;
    }

    /**
     * Adds the specified amount of days.
     *
     * @param numberOfDays the number of days to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addDays(long numberOfDays) {
      validateOrder(4);
      validateDays(numberOfDays, 1);
      days += (int) numberOfDays;
      return this;
    }

    /**
     * Adds the specified amount of hours.
     *
     * @param numberOfHours the number of hours to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addHours(long numberOfHours) {
      validateOrder(5);
      validateNanos(numberOfHours, NANOS_PER_HOUR);
      nanoseconds += numberOfHours * NANOS_PER_HOUR;
      return this;
    }

    /**
     * Adds the specified amount of minutes.
     *
     * @param numberOfMinutes the number of minutes to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addMinutes(long numberOfMinutes) {
      validateOrder(6);
      validateNanos(numberOfMinutes, NANOS_PER_MINUTE);
      nanoseconds += numberOfMinutes * NANOS_PER_MINUTE;
      return this;
    }

    /**
     * Adds the specified amount of seconds.
     *
     * @param numberOfSeconds the number of seconds to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addSeconds(long numberOfSeconds) {
      validateOrder(7);
      validateNanos(numberOfSeconds, NANOS_PER_SECOND);
      nanoseconds += numberOfSeconds * NANOS_PER_SECOND;
      return this;
    }

    /**
     * Adds the specified amount of milliseconds.
     *
     * @param numberOfMillis the number of milliseconds to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addMillis(long numberOfMillis) {
      validateOrder(8);
      validateNanos(numberOfMillis, NANOS_PER_MILLI);
      nanoseconds += numberOfMillis * NANOS_PER_MILLI;
      return this;
    }

    /**
     * Adds the specified amount of microseconds.
     *
     * @param numberOfMicros the number of microseconds to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addMicros(long numberOfMicros) {
      validateOrder(9);
      validateNanos(numberOfMicros, NANOS_PER_MICRO);
      nanoseconds += numberOfMicros * NANOS_PER_MICRO;
      return this;
    }

    /**
     * Adds the specified amount of nanoseconds.
     *
     * @param numberOfNanos the number of nanoseconds to add.
     * @return this {@code Builder}
     */
    @NonNull
    public Builder addNanos(long numberOfNanos) {
      validateOrder(10);
      validateNanos(numberOfNanos, 1);
      nanoseconds += numberOfNanos;
      return this;
    }

    /**
     * Validates that the total number of months can be stored.
     *
     * @param units the number of units that need to be added
     * @param monthsPerUnit the number of days per unit
     */
    private void validateMonths(long units, int monthsPerUnit) {
      validate(units, (Integer.MAX_VALUE - months) / monthsPerUnit, "months");
    }

    /**
     * Validates that the total number of days can be stored.
     *
     * @param units the number of units that need to be added
     * @param daysPerUnit the number of days per unit
     */
    private void validateDays(long units, int daysPerUnit) {
      validate(units, (Integer.MAX_VALUE - days) / daysPerUnit, "days");
    }

    /**
     * Validates that the total number of nanoseconds can be stored.
     *
     * @param units the number of units that need to be added
     * @param nanosPerUnit the number of nanoseconds per unit
     */
    private void validateNanos(long units, long nanosPerUnit) {
      validate(units, (Long.MAX_VALUE - nanoseconds) / nanosPerUnit, "nanoseconds");
    }

    /**
     * Validates that the specified amount is less than the limit.
     *
     * @param units the number of units to check
     * @param limit the limit on the number of units
     * @param unitName the unit name
     */
    private void validate(long units, long limit, @NonNull String unitName) {
      Preconditions.checkArgument(
          units <= limit,
          "Invalid duration. The total number of %s must be less or equal to %s",
          unitName,
          Integer.MAX_VALUE);
    }

    /**
     * Validates that the duration values are added in the proper order.
     *
     * @param unitIndex the unit index (e.g. years=1, months=2, ...)
     */
    private void validateOrder(int unitIndex) {
      if (unitIndex == currentUnitIndex) {
        throw new IllegalArgumentException(
            String.format(
                "Invalid duration. The %s are specified multiple times", getUnitName(unitIndex)));
      }
      if (unitIndex <= currentUnitIndex) {
        throw new IllegalArgumentException(
            String.format(
                "Invalid duration. The %s should be after %s",
                getUnitName(currentUnitIndex), getUnitName(unitIndex)));
      }
      currentUnitIndex = unitIndex;
    }

    /**
     * Returns the name of the unit corresponding to the specified index.
     *
     * @param unitIndex the unit index
     * @return the name of the unit corresponding to the specified index.
     */
    @NonNull
    private String getUnitName(int unitIndex) {
      switch (unitIndex) {
        case 1:
          return "years";
        case 2:
          return "months";
        case 3:
          return "weeks";
        case 4:
          return "days";
        case 5:
          return "hours";
        case 6:
          return "minutes";
        case 7:
          return "seconds";
        case 8:
          return "milliseconds";
        case 9:
          return "microseconds";
        case 10:
          return "nanoseconds";
        default:
          throw new AssertionError("unknown unit index: " + unitIndex);
      }
    }

    @NonNull
    public CqlDuration build() {
      return isNegative
          ? new CqlDuration(-months, -days, -nanoseconds)
          : new CqlDuration(months, days, nanoseconds);
    }
  }
}
