/*
 * 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 org.apache.gobblin.hive;

import com.google.common.base.Strings;
import java.util.Set;

import org.apache.avro.Schema;
import org.apache.gobblin.util.AvroUtils;
import org.apache.hadoop.fs.Path;

import com.google.common.base.Optional;

import org.apache.gobblin.annotation.Alpha;
import org.apache.gobblin.configuration.State;
import org.apache.hadoop.hive.serde2.avro.AvroSerdeUtils;


/**
 * A comparator between an existing {@link HiveRegistrationUnit} and a new {@link HiveRegistrationUnit}. It is
 * used to determine whether the existing {@link HiveRegistrationUnit} should be altered to match the new
 * {@link HiveRegistrationUnit}.
 *
 * <p>
 *   Since altering a Hive table/partition is relatively expensive, when registering a new table/partition, if the
 *   table/partition exists, it is usually beneficial to check whether the existing table/partition needs to be
 *   altered before altering it.
 * </p>
 *
 * <p>
 *   This class does <em>not</em> implement {@link java.util.Comparator} and does not conform to the contract of
 *   {@link java.util.Comparator}.
 * </p>
 *
 * <p>
 *   Sample usage:
 *
 *   <pre> {@code
 *     HiveRegistrationUnitComparator<?> comparator = new HiveRegistrationUnitComparator<>(existingTable, newTable);
 *     boolean needToUpdate = comparator.compareInputFormat().compareOutputFormat().compareNumBuckets()
 *      .compareIsCompressed().compareRawLocation().result();
 *     }}
 *   </pre>
 *
 *   Or to compare all fields:
 *
 *   <pre> {@code
 *     HiveRegistrationUnitComparator<?> comparator = new HiveRegistrationUnitComparator<>(existingTable, newTable);
 *     boolean needToUpdate = comparator.compareAll().result();
 *     }}
 *   </pre>
 * </p>
 *
 * @author Ziyang Liu
 */
@Alpha
public class HiveRegistrationUnitComparator<T extends HiveRegistrationUnitComparator<?>> {
  private static String SCHEMA_CREATION_TIME = "schema.creationTime";

  protected final HiveRegistrationUnit existingUnit;
  protected final HiveRegistrationUnit newUnit;

  protected boolean result = false;

  public HiveRegistrationUnitComparator(HiveRegistrationUnit existingUnit, HiveRegistrationUnit newUnit) {
    this.existingUnit = existingUnit;
    this.newUnit = newUnit;
  }

  /**
   * Compare the raw locations (without schema and authority).
   *
   * <p>
   *   This is useful since existing tables/partitions in the Hive metastore have absolute paths in the location
   *   property, but the new table/partition may have a raw path.
   * </p>
   */
  @SuppressWarnings("unchecked")
  public T compareRawLocation() {
    if (!this.result) {
      this.result |= (!new Path(this.existingUnit.getLocation().get()).toUri().getRawPath()
          .equals(new Path(this.newUnit.getLocation().get()).toUri().getRawPath()));
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareInputFormat() {
    if (!this.result) {
      compare(this.existingUnit.getInputFormat(), this.newUnit.getInputFormat());
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareOutputFormat() {
    if (!this.result) {
      compare(this.existingUnit.getOutputFormat(), this.newUnit.getOutputFormat());
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareIsCompressed() {
    if (!this.result) {
      compare(this.existingUnit.getIsCompressed(), this.newUnit.getIsCompressed());
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareNumBuckets() {
    if (!this.result) {
      compare(this.existingUnit.getNumBuckets(), this.newUnit.getNumBuckets());
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareBucketCols() {
    if (!this.result) {
      compare(this.existingUnit.getBucketColumns(), this.newUnit.getBucketColumns());
    }
    return (T) this;
  }

  @SuppressWarnings("unchecked")
  public T compareIsStoredAsSubDirs() {
    if (!this.result) {
      compare(this.existingUnit.getIsStoredAsSubDirs(), this.newUnit.getIsStoredAsSubDirs());
    }
    return (T) this;
  }

  private State extractSchemaVersion(State state) {
    //FIXME: This is a temp fix for special character in schema string, need to investigate the root
    //cause of why we see different encoding here and have a permanent fix for this
    State newState = new State(state);
    String schemaFromState = state.getProp(AvroSerdeUtils.AvroTableProperties.SCHEMA_LITERAL.getPropName());
    if (!Strings.isNullOrEmpty(schemaFromState)) {
      String schemaVersion = AvroUtils.getSchemaCreationTime(new Schema.Parser().parse(schemaFromState));
      if (!Strings.isNullOrEmpty(schemaVersion)) {
         newState.removeProp(AvroSerdeUtils.AvroTableProperties.SCHEMA_LITERAL.getPropName());
         newState.setProp(SCHEMA_CREATION_TIME, schemaVersion);
      }
    }
    return newState;
  }
  @SuppressWarnings("unchecked")
  public T compareParameters() {
    if (!this.result) {
      checkExistingIsSuperstate(this.existingUnit.getProps(), this.newUnit.getProps());
      checkExistingIsSuperstate(this.existingUnit.getStorageProps(), this.newUnit.getStorageProps());
      checkExistingIsSuperstate(extractSchemaVersion(this.existingUnit.getSerDeProps()), extractSchemaVersion(this.newUnit.getSerDeProps()));
    }
    return (T) this;
  }

  /**
   * Compare all parameters.
   */
  @SuppressWarnings("unchecked")
  public T compareAll() {
    this.compareInputFormat().compareOutputFormat().compareIsCompressed().compareIsStoredAsSubDirs().compareNumBuckets()
        .compareBucketCols().compareRawLocation().compareParameters();
    return (T) this;
  }

  /**
   * Compare an existing value and a new value, and set {@link #result} accordingly.
   *
   * <p>
   *   This method returns false if newValue is absent (i.e., the existing value doesn't need to be updated).
   *   This is because when adding a table/partition to Hive, Hive automatically sets default values for
   *   some of the unspecified parameters. Therefore existingValue being present and newValue being absent
   *   doesn't mean the existing value needs to be updated.
   * </p>
   */
  protected <E> void compare(Optional<E> existingValue, Optional<E> newValue) {
    boolean different;
    if (!newValue.isPresent()) {
      different = false;
    } else {
      different = !existingValue.isPresent() || !existingValue.get().equals(newValue.get());
    }
    this.result |= different;
  }

  /**
   * Compare an existing state and a new {@link State} to ensure that the existing {@link State} contains all entries in the new
   * {@link State}, and update {@link #result} accordingly.
   */
  protected void checkExistingIsSuperstate(State existingState, State newState) {
    checkExistingIsSuperset(existingState.getProperties().entrySet(), newState.getProperties().entrySet());
  }

  /**
   * Compare an existing state and a new {@link Set} to ensure that the existing {@link Set} contains all entries in the new
   * {@link Set}, and update {@link #result} accordingly.
   */
  protected <E> void checkExistingIsSuperset(Set<E> existingSet, Set<E> newSet) {
    this.result |= !existingSet.containsAll(newSet);
  }

  /**
   * Get the result of comparison.
   * @return true if the existing {@link HiveRegistrationUnit} needs to be altered, false otherwise.
   */
  public boolean result() {
    boolean resultCopy = this.result;
    this.result = false;
    return resultCopy;
  }

}
