/*
 * 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;

import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.jcip.annotations.Immutable;

/**
 * A structured version number.
 *
 * <p>It is in the form X.Y.Z, with optional pre-release labels and build metadata.
 *
 * <p>Version numbers compare the usual way, the major number (X) is compared first, then the minor
 * one (Y) and then the patch level one (Z). Lastly, versions with pre-release sorts before the
 * versions that don't have one, and labels are sorted alphabetically if necessary. Build metadata
 * are ignored for sorting versions.
 */
@Immutable
public class Version implements Comparable<Version>, Serializable {

  private static final long serialVersionUID = 1;

  private static final String VERSION_REGEXP =
      "(\\d+)\\.(\\d+)(\\.\\d+)?(\\.\\d+)?([~\\-]\\w[.\\w]*(?:-\\w[.\\w]*)*)?(\\+[.\\w]+)?";

  private static final Pattern pattern = Pattern.compile(VERSION_REGEXP);

  @NonNull public static final Version V1_0_0 = Objects.requireNonNull(parse("1.0.0"));
  @NonNull public static final Version V2_1_0 = Objects.requireNonNull(parse("2.1.0"));
  @NonNull public static final Version V2_2_0 = Objects.requireNonNull(parse("2.2.0"));
  @NonNull public static final Version V3_0_0 = Objects.requireNonNull(parse("3.0.0"));
  @NonNull public static final Version V4_0_0 = Objects.requireNonNull(parse("4.0.0"));
  @NonNull public static final Version V4_1_0 = Objects.requireNonNull(parse("4.1.0"));
  @NonNull public static final Version V5_0_0 = Objects.requireNonNull(parse("5.0.0"));
  @NonNull public static final Version V6_7_0 = Objects.requireNonNull(parse("6.7.0"));
  @NonNull public static final Version V6_8_0 = Objects.requireNonNull(parse("6.8.0"));
  @NonNull public static final Version V6_9_0 = Objects.requireNonNull(parse("6.9.0"));

  private final int major;
  private final int minor;
  private final int patch;
  private final int dsePatch;

  private final String[] preReleases;
  private final String build;

  private Version(
      int major, int minor, int patch, int dsePatch, String[] preReleases, String build) {
    this.major = major;
    this.minor = minor;
    this.patch = patch;
    this.dsePatch = dsePatch;
    this.preReleases = preReleases;
    this.build = build;
  }

  /**
   * Parses a version from a string.
   *
   * <p>The version string should have primarily the form X.Y.Z to which can be appended one or more
   * pre-release label after dashes (2.0.1-beta1, 2.1.4-rc1-SNAPSHOT) and an optional build label
   * (2.1.0-beta1+a20ba.sha). Out of convenience, the "patch" version number, Z, can be omitted, in
   * which case it is assumed to be 0.
   *
   * @param version the string to parse.
   * @return the parsed version number.
   * @throws IllegalArgumentException if the provided string does not represent a valid version.
   */
  @Nullable
  public static Version parse(@Nullable String version) {
    if (version == null) {
      return null;
    }

    Matcher matcher = pattern.matcher(version);
    if (!matcher.matches()) {
      throw new IllegalArgumentException("Invalid version number: " + version);
    }

    try {
      int major = Integer.parseInt(matcher.group(1));
      int minor = Integer.parseInt(matcher.group(2));

      String pa = matcher.group(3);
      int patch =
          pa == null || pa.isEmpty()
              ? 0
              : Integer.parseInt(
                  pa.substring(1)); // dropping the initial '.' since it's included this time

      String dse = matcher.group(4);
      int dsePatch =
          dse == null || dse.isEmpty()
              ? -1
              : Integer.parseInt(
                  dse.substring(1)); // dropping the initial '.' since it's included this time

      String pr = matcher.group(5);
      String[] preReleases =
          pr == null || pr.isEmpty()
              ? null
              : pr.substring(1)
                  .split("-"); // drop initial '-' or '~' then split on the remaining ones

      String bl = matcher.group(6);
      String build = bl == null || bl.isEmpty() ? null : bl.substring(1); // drop the initial '+'

      return new Version(major, minor, patch, dsePatch, preReleases, build);
    } catch (NumberFormatException e) {
      throw new IllegalArgumentException("Invalid version number: " + version);
    }
  }

  /**
   * The major version number.
   *
   * @return the major version number, i.e. X in X.Y.Z.
   */
  public int getMajor() {
    return major;
  }

  /**
   * The minor version number.
   *
   * @return the minor version number, i.e. Y in X.Y.Z.
   */
  public int getMinor() {
    return minor;
  }

  /**
   * The patch version number.
   *
   * @return the patch version number, i.e. Z in X.Y.Z.
   */
  public int getPatch() {
    return patch;
  }

  /**
   * The DSE patch version number (will only be present for version of Cassandra in DSE).
   *
   * <p>DataStax Entreprise (DSE) adds a fourth number to the version number to track potential hot
   * fixes and/or DSE specific patches that may have been applied to the Cassandra version. In that
   * case, this method returns that fourth number.
   *
   * @return the DSE patch version number, i.e. D in X.Y.Z.D, or -1 if the version number is not
   *     from DSE.
   */
  public int getDSEPatch() {
    return dsePatch;
  }

  /**
   * The pre-release labels if relevant, i.e. label1 and label2 in X.Y.Z-label1-lable2.
   *
   * @return the pre-release labels. The return list will be {@code null} if the version number
   *     doesn't have any.
   */
  public List<String> getPreReleaseLabels() {
    return preReleases == null ? null : Collections.unmodifiableList(Arrays.asList(preReleases));
  }

  /**
   * The build label if there is one.
   *
   * @return the build label or {@code null} if the version number doesn't have one.
   */
  public String getBuildLabel() {
    return build;
  }

  /**
   * The next stable version, i.e. the version stripped of its pre-release labels and build
   * metadata.
   *
   * <p>This is mostly used during our development stage, where we test the driver against
   * pre-release versions of Cassandra like 2.1.0-rc7-SNAPSHOT, but need to compare to the stable
   * version 2.1.0 when testing for native protocol compatibility, etc.
   *
   * @return the next stable version.
   */
  public Version nextStable() {
    return new Version(major, minor, patch, dsePatch, null, null);
  }

  @Override
  public int compareTo(@NonNull Version other) {
    if (major < other.major) {
      return -1;
    }
    if (major > other.major) {
      return 1;
    }

    if (minor < other.minor) {
      return -1;
    }
    if (minor > other.minor) {
      return 1;
    }

    if (patch < other.patch) {
      return -1;
    }
    if (patch > other.patch) {
      return 1;
    }

    if (dsePatch < 0) {
      if (other.dsePatch >= 0) {
        return -1;
      }
    } else {
      if (other.dsePatch < 0) {
        return 1;
      }

      // Both are >= 0
      if (dsePatch < other.dsePatch) {
        return -1;
      }
      if (dsePatch > other.dsePatch) {
        return 1;
      }
    }

    if (preReleases == null) {
      return other.preReleases == null ? 0 : 1;
    }
    if (other.preReleases == null) {
      return -1;
    }

    for (int i = 0; i < Math.min(preReleases.length, other.preReleases.length); i++) {
      int cmp = preReleases[i].compareTo(other.preReleases[i]);
      if (cmp != 0) {
        return cmp;
      }
    }

    return Integer.compare(preReleases.length, other.preReleases.length);
  }

  @Override
  public boolean equals(Object other) {
    if (other == this) {
      return true;
    } else if (other instanceof Version) {
      Version that = (Version) other;
      return this.major == that.major
          && this.minor == that.minor
          && this.patch == that.patch
          && this.dsePatch == that.dsePatch
          && (this.preReleases == null
              ? that.preReleases == null
              : Arrays.equals(this.preReleases, that.preReleases))
          && Objects.equals(this.build, that.build);
    } else {
      return false;
    }
  }

  @Override
  public int hashCode() {
    return Objects.hash(major, minor, patch, dsePatch, Arrays.hashCode(preReleases), build);
  }

  @Override
  public String toString() {
    StringBuilder sb = new StringBuilder();
    sb.append(major).append('.').append(minor).append('.').append(patch);
    if (dsePatch >= 0) {
      sb.append('.').append(dsePatch);
    }
    if (preReleases != null) {
      for (String preRelease : preReleases) {
        sb.append('-').append(preRelease);
      }
    }
    if (build != null) {
      sb.append('+').append(build);
    }
    return sb.toString();
  }
}
