package thredds.crawlabledataset.s3;

import java.io.File;
import com.google.common.base.Preconditions;

/**
 * An identifier for objects stored in Amazon S3. Ultimately, the identifier is composed solely of a bucket name and
 * a key, and an object of this class can be constructed with those two items. However, this class also supports URIs
 * rendered in a path-like form: {@code s3://<bucket>/<key>}.
 * <p>
 * Instances of this class are immutable.
 *
 * @author cwardgar
 * @since 2015/08/24
 */
public class S3URI {
  public static final String S3_PREFIX = "s3://";
  public static final String S3_DELIMITER = "/";
  public static final File S3ObjectTempDir = new File(System.getProperty("java.io.tmpdir"), "S3Objects");

  private final String bucket, key;

  /**
   * Creates a S3URI by extracting the S3 bucket and key names from {@code uri}. If the key has a trailing
   * {@link #S3_DELIMITER delimiter}, it will be removed.
   *
   * @param uri an S3 URI in the form {@code s3://<bucket>/<key>}.
   * @throws IllegalArgumentException if {@code uri} is not in the expected form or if the bucket or key are invalid.
   * @see #S3URI(String, String)
   */
  public S3URI(String uri) {
    if (uri.startsWith(S3_PREFIX)) {
      uri = uri.substring(S3_PREFIX.length(), uri.length());

      int delimPos = uri.indexOf(S3_DELIMITER);
      if (delimPos == -1) { // Handle case where uri includes bucket but no key, e.g. "s3://bucket".
        this.bucket = checkBucket(uri);
        this.key = checkKey(null);
      } else {
        this.bucket = checkBucket(uri.substring(0, delimPos));
        this.key = checkKey(uri.substring(delimPos + 1, uri.length()));
      }
    } else {
      throw new IllegalArgumentException(
          String.format("S3 URI '%s' does not start with the expected prefix '%s'.", uri, S3_PREFIX));
    }
  }

  /**
   * Creates a S3URI from the specified bucket and key. If the key has a trailing
   * {@link #S3_DELIMITER delimiter}, it will be removed.
   *
   * @param bucket a bucket name. Must be non-{@code null} and at least 3 characters.
   * @param key a key. May be {@code null} but cannot be the empty string. Also, it may not contain consecutive
   *        delimiters.
   * @throws IllegalArgumentException if either argument fails the requirements.
   */
  public S3URI(String bucket, String key) throws IllegalArgumentException {
    this.bucket = checkBucket(bucket);
    this.key = checkKey(key);
  }

  private static String checkBucket(String bucket) throws IllegalArgumentException {
    Preconditions.checkNotNull(bucket, "Bucket must be non-null.");
    if (bucket.length() < 3) {
      throw new IllegalArgumentException(String.format("Bucket name '%s' must be at least 3 characters.", bucket));
    }
    return bucket;
  }

  private static String checkKey(String key) throws IllegalArgumentException {
    if (key == null) {
      return null;
    } else if (key.equals("")) {
      throw new IllegalArgumentException("Key may not be the empty string.");
    } else {
      if (key.contains(S3_DELIMITER + S3_DELIMITER)) { // Key contains consecutive delimiters.
        throw new IllegalArgumentException(String.format("Key '%s' contains consecutive delimiters.", key));
      }

      if (key.endsWith(S3_DELIMITER)) {
        return key.substring(0, key.length() - 1); // Remove trailing delimiter.
      } else {
        return key;
      }
    }
  }


  /**
   * Returns the bucket.
   *
   * @return the bucket.
   */
  public String getBucket() {
    return bucket;
  }

  /**
   * Returns the key. May be {@code null} if the URI did not include a key, e.g. "s3://bucket". If not null,
   * any trailing {@code #S3_DELIMITER delimiter} the key had when passed to the constructor will have been stripped.
   *
   * @return the key.
   */
  public String getKey() {
    return key;
  }

  /**
   * Returns the key, adding a trailing {@link #S3_DELIMITER delimiter}.
   * Returns {@code null} if the key is {@code null}.
   *
   * @return the key, with a trailing delimiter.
   */
  public String getKeyWithTrailingDelimiter() {
    if (key == null) {
      return null;
    } else {
      assert !key.endsWith(S3_DELIMITER) : "Didn't we strip this in the ctor?";
      return key + S3_DELIMITER;
    }
  }

  /**
   * Returns the base name of the file or directory denoted by this URI. This is just the last name in the
   * key's name sequence. If the key is {@code null}, {@code null} is returned.
   *
   * @return the base name.
   */
  public String getBaseName() {
    if (key == null) {
      return null;
    } else {
      return new File(key).getName();
    }
  }

  /**
   * Returns the parent URI of this URI. The determination is completely text-based, using the
   * {@link #S3_DELIMITER delimiter}. If the key is {@code null}, {@code null} is returned. If it is non-{@code null},
   * but doesn't have a logical parent, the returned URI will have a {@code null} key (but the same bucket).
   * For example, the parent of {@code s3://my-bucket/my-key} (bucket="my-bucket", key="my-key") will be
   * {@code s3://my-bucket} (bucket=="my-bucket", key=null).
   *
   * @return the parent URI of this URI.
   */
  public S3URI getParent() {
    if (key == null) {
      return null;
    }

    int lastDelimPos = key.lastIndexOf(S3_DELIMITER);
    if (lastDelimPos == -1) {
      return new S3URI(bucket, null);
    } else {
      return new S3URI(bucket, key.substring(0, lastDelimPos));
    }
  }

  /**
   * Creates a new URI by resolving the specified path relative to {@code this}. If {@code key == null}, the key
   * of the returned URI will simply be {@code relativePath}.
   *
   * @param relativePath a path relative to {@code this}. Must be non-null.
   * @return the child URI.
   * @throws IllegalArgumentException if the path starts with a {@link #S3_DELIMITER delimiter}.
   *         The path must be relative.
   */
  public S3URI getChild(String relativePath) throws IllegalArgumentException {
    Preconditions.checkNotNull(relativePath, "relativePath must be non-null.");

    if (relativePath.isEmpty()) {
      return this;
    } else if (relativePath.startsWith(S3_DELIMITER)) {
      throw new IllegalArgumentException(String.format(
          "Path '%s' should be relative but begins with the delimiter string '%s'.", relativePath, S3_DELIMITER));
    }

    if (key == null) {
      return new S3URI(bucket, relativePath);
    } else {
      return new S3URI(bucket, key + S3_DELIMITER + relativePath);
    }
  }

  /**
   * Gets a temporary file to which the content of the S3Object that this URI points to can be downloaded.
   * The path of the file is {@code ${java.io.tmpdir}/S3Objects/${hashCode()}/${getBaseName()}}.
   * This method does not cause the file to be created; we're just returning a suitable path.
   *
   * @return a temporary file to which the content of the S3Object that this URI points to can be downloaded.
   */
  public File getTempFile() {
    // To avoid collisions of files with the same name, create a parent dir named after the S3URI's hashCode().
    File parentDir = new File(S3ObjectTempDir, String.valueOf(hashCode()));
    return new File(parentDir, getBaseName());
  }

  //////////////////////////////////////// Object ////////////////////////////////////////

  /**
   * Returns a string representation of the URI in the form {@code s3://<bucket>/<key>}.
   *
   * @return a string representation of the URI.
   */
  @Override
  public String toString() {
    StringBuilder strBuilder = new StringBuilder();
    strBuilder.append(S3_PREFIX);
    strBuilder.append(bucket);

    if (key != null) {
      strBuilder.append(S3_DELIMITER);
      strBuilder.append(key);
    }

    return strBuilder.toString();
  }

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

    S3URI other = (S3URI) o;
    return this.bucket.equals(other.bucket) && this.key == null ? other.key == null : this.key.equals(other.key);
  }

  @Override
  public int hashCode() {
    int result = bucket.hashCode();
    result = 31 * result + (key != null ? key.hashCode() : 0);
    return result;
  }
}
