/*
 * The Alluxio Open Foundation licenses this work under the Apache License, version 2.0
 * (the "License"). You may not use this work except in compliance with the License, which is
 * available at www.apache.org/licenses/LICENSE-2.0
 *
 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied, as more fully set forth in the License.
 *
 * See the NOTICE file distributed with this work for information regarding copyright ownership.
 */

package alluxio.s3;

import alluxio.conf.Configuration;
import alluxio.conf.PropertyKey;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty;
import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement;
import com.google.protobuf.ByteString;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
import javax.xml.bind.annotation.XmlTransient;

/**
 * Parsed version of the S3 tagging XML.
 * Used by the following S3 API actions:
 * - GetBucketTagging, GetObjectTagging, PutBucketTagging, PutObjectTagging
 */
@JacksonXmlRootElement(localName = "Tagging")
@JsonIgnoreProperties(ignoreUnknown = true, value = {"tagMap"})
public class TaggingData {
  private static final XmlMapper MAPPER = new XmlMapper();
  private static final boolean TAG_RESTRICTIONS_ENABLED = Configuration.getBoolean(
      PropertyKey.PROXY_S3_TAGGING_RESTRICTIONS_ENABLED);

  @XmlTransient
  private final Map<String, String> mTagMap;

  /**
   * Deserialize the XML (bytes).
   * @param bytes byte array of the Java-serialized object
   * @return a deserialized {@link TaggingData} object
   */
  public static TaggingData deserialize(byte[] bytes)
      throws IOException {
    return MAPPER.readerFor(TaggingData.class).readValue(bytes);
  }

  /**
   * Serializes the object as an XML (bytes).
   * @param tagData the {@link TaggingData} object to serialize
   * @return the {@link ByteString} serialization of this object
   */
  public static ByteString serialize(TaggingData tagData)
      throws JsonProcessingException {
    return ByteString.copyFrom(MAPPER.writeValueAsBytes(tagData));
  }

  @JacksonXmlProperty(localName = "TagSet")
  private TagSet mTagSet;

  /**
   * Default constructor for jackson.
   */
  public TaggingData() {
    this(new TagSet());
  }

  /**
   * Create a new {@link TaggingData}.
   *
   * @param tagSet the user metadata tags
   */
  public TaggingData(@Nullable TagSet tagSet) {
    mTagMap = new HashMap<>();
    setTagSet(tagSet); // use setter method for tag validation
  }

  /**
   * Sets the tag set.
   *
   * @param tagSet set of user metadata tags
   */
  @JacksonXmlProperty(localName = "TagSet")
  private void setTagSet(@Nullable TagSet tagSet) {
    if (tagSet == null) { // forced to accept null parameter for unmarshalling XML strings
      tagSet = new TagSet();
    }
    mTagSet = tagSet;
    validateTags();
    repopulateTagMap();
  }

  /**
   * @return the tag set
   */
  @JacksonXmlProperty(localName = "TagSet")
  private TagSet getTagSet() {
    return mTagSet;
  }

  @Override
  public String toString() {
    return mTagSet.toString();
  }

  /**
   * Returns the Map object containing the contents of
   * the TagSet. Useful for parsing or iterating over tag data.
   * @return a Map object containing the tags
   */
  @XmlTransient
  public Map<String, String> getTagMap() {
    return mTagMap;
  }

  /**
   * Adds the provided key-value pair as a new tag
   * Overwrites any duplicate tags.
   * @param key the tag's key
   * @param value the tag's value
   */
  private void addTag(String key, String value) {
    if (!mTagMap.containsKey(key)) {
      mTagSet.mTags.add(new TagObject(key, value));
      mTagMap.put(key, value);
    } // else, we have to update the existing tag
    for (TagObject tag : mTagSet.mTags) {
      if (tag.mKey.equals(key)) {
        tag.mValue = value;
        break;
      }
    }
    mTagMap.put(key, value);
  }

  /**
   * Merges the contents of the provided tag map with
   * the existing tags. Overwrites any duplicate tags.
   * @param tagMap a collection of Tags represented by key-value pairs
   * @return a reference to this object
   */
  @XmlTransient
  public TaggingData addTags(Map<String, String> tagMap) throws IllegalArgumentException {
    for (Map.Entry<String, String> tag : tagMap.entrySet()) {
      addTag(tag.getKey(), tag.getValue());
    }
    validateTags();
    return this;
  }

  /**
   * Removes all tags contained in this object.
   * @return a reference to this object
   */
  @XmlTransient
  public TaggingData clear() {
    mTagMap.clear();
    mTagSet.clear();
    return this;
  }

  /**
   * Fills the tag map with the contents of the tag set.
   */
  private void repopulateTagMap() {
    mTagMap.clear();
    for (TagObject tag : mTagSet.mTags) {
      mTagMap.put(tag.mKey, tag.mValue);
    }
  }

  /**
   * Validates S3 User tag restrictions.
   */
  private void validateTags() throws IllegalArgumentException {
    List<TagObject> tags = mTagSet.getTags();
    if (tags.size() == 0) { return; }
    try {
      if (TAG_RESTRICTIONS_ENABLED && tags.size() > 10) {
        throw new S3Exception(new S3ErrorCode(
            S3ErrorCode.INVALID_TAG.getCode(),
            "User-defined metadata tags cannot be greater than 10",
            S3ErrorCode.INVALID_TAG.getStatus()));
      }
      Set<String> tagKeys = new HashSet<>();
      for (TagObject tag : tags) {
        // Tag key validation
        if (TAG_RESTRICTIONS_ENABLED && tag.mKey.length() > 128) {
          throw new S3Exception(new S3ErrorCode(
              S3ErrorCode.INVALID_TAG.getCode(),
              "Tag key exceeds maximum length (128): " + tag.mKey,
              S3ErrorCode.INVALID_TAG.getStatus()));
        }
        if (tagKeys.contains(tag.mKey)) {
          throw new S3Exception(new S3ErrorCode(
              S3ErrorCode.INVALID_TAG.getCode(),
              "Tags cannot contain duplicate keys",
              S3ErrorCode.INVALID_TAG.getStatus()));
        }
        tagKeys.add(tag.mKey);
        // Tag value validation
        if (TAG_RESTRICTIONS_ENABLED && tag.mValue.length() > 256) {
          throw new S3Exception(new S3ErrorCode(
              S3ErrorCode.INVALID_TAG.getCode(),
              "Tag value exceeds maximum length (256): "
                  + tag.mKey + "=" + tag.mValue,
              S3ErrorCode.INVALID_TAG.getStatus()));
        }
      }
    } catch (S3Exception e) {
      // IllegalArgumentException will be consumed by IOException from the
      // jersey library when parsing the XML into this object
      // - the underlying S3Exception will be the throwable cause for the IOException
      throw new IllegalArgumentException(e);
    }
  }

  /**
   * Inner POJO representing the user metadata tag set in the S3 API.
   */
  @JacksonXmlRootElement(localName = "TagSet")
  private static class TagSet {
    @JacksonXmlProperty(localName = "Tag")
    @JacksonXmlElementWrapper(useWrapping = false)
    private List<TagObject> mTags;

    /**
     * Default constructor for jackson.
     */
    public TagSet() { this(new ArrayList<TagObject>()); }

    /**
     * @param tags the tags
     */
    public TagSet(List<TagObject> tags) {
      mTags = tags;
    }

    /**
     * @return the tags
     */
    @JacksonXmlProperty(localName = "Tag")
    private List<TagObject> getTags() {
      return mTags;
    }

    /**
     * Sets the key to the object.
     *
     * @param tags the tags
     */
    @JacksonXmlProperty(localName = "Tag")
    private void setTags(List<TagObject> tags) {
      mTags = tags;
    }

    private void clear() {
      mTags.clear();
    }

    @Override
    public String toString() {
      return mTags.toString();
    }
  }

  /**
   * Inner POJO representing a user metadata tag in the S3 API.
   */
  @JacksonXmlRootElement(localName = "Tag")
  private static class TagObject {
    private String mKey;
    private String mValue;

    /**
     * Default constructor for jackson.
     */
    public TagObject() { }

    /**
     * @param key the key indicating the tag name
     * @param value the key indicating the tag value
     */
    public TagObject(String key, String value) {
      mKey = key;
      mValue = value;
    }

    /**
     * @return the key of the object
     */
    @JacksonXmlProperty(localName = "Key")
    private String getKey() {
      return mKey;
    }

    /**
     * @return the key of the object
     */
    @JacksonXmlProperty(localName = "Value")
    private String getValue() {
      return mValue;
    }

    /**
     * Sets the key to the object.
     *
     * @param key the key
     */
    @JacksonXmlProperty(localName = "Key")
    private void setKey(String key) {
      mKey = key;
    }

    /**
     * Sets the value to the object.
     *
     * @param value the value
     */
    @JacksonXmlProperty(localName = "Value")
    private void setValue(String value) {
      mValue = value;
    }

    @Override
    public String toString() {
      return mKey + " = " + mValue;
    }
  }
}
