// Copyright 2016 The Bazel Authors. All rights reserved.
//
// Licensed 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.google.devtools.build.android;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Stopwatch;
import com.google.common.collect.Iterables;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.devtools.build.android.AndroidResourceMerger.MergingException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;

/** Handles the Merging of ParsedAndroidData. */
class AndroidDataMerger {

  public static class MergeConflictException extends UserException {

    private MergeConflictException(String message) {
      super(message);
    }

    static MergeConflictException withMessage(String message) {
      return new MergeConflictException(message);
    }
  }

  private static final Logger logger = Logger.getLogger(AndroidDataMerger.class.getCanonicalName());

  /** Interface for comparing paths. */
  interface SourceChecker {
    boolean checkEquality(DataSource one, DataSource two) throws IOException;
  }

  /** Compares two paths by the contents of the files. */
  static class ContentComparingChecker implements SourceChecker {

    static SourceChecker create() {
      return new ContentComparingChecker();
    }

    @Override
    public boolean checkEquality(DataSource one, DataSource two) throws IOException {
      if (one.getPath().equals(two.getPath())) {
        return true;
      }
      // TODO(corysmith): Is there a filesystem hash we can use?
      if (one.getFileSize() != two.getFileSize()) {
        return false;
      }
      try (final InputStream oneStream = one.newBufferedInputStream();
          final InputStream twoStream = two.newBufferedInputStream()) {
        int bytesRead = 0;
        while (true) {
          int oneByte = oneStream.read();
          int twoByte = twoStream.read();
          bytesRead++;
          if (oneByte == -1 || twoByte == -1) {
            if (oneByte == twoByte) {
              return true;
            } else {
              // getFileSize did not return correct size.
              logger.severe(
                  String.format(
                      "Filesystem size of %s (%s) or %s (%s) is inconsistent with bytes read %s.",
                      one, one.getFileSize(), two, two.getFileSize(), bytesRead));
              return false;
            }
          }
          if (oneByte != twoByte) {
            return false;
          }
        }
      }
    }
  }

  static class NoopSourceChecker implements SourceChecker {
    static SourceChecker create() {
      return new NoopSourceChecker();
    }

    @Override
    public boolean checkEquality(DataSource one, DataSource two) {
      return false;
    }
  }

  private final SourceChecker deDuplicator;
  private final ListeningExecutorService executorService;
  private final AndroidDataDeserializer deserializer;

  /** Creates a merger with no path deduplication and a default {@link ExecutorService}. */
  @VisibleForTesting
  static AndroidDataMerger createWithDefaults() {
    return createWithDefaultThreadPool(NoopSourceChecker.create());
  }

  /** Creates a merger with a custom deduplicator and a default {@link ExecutorService}. */
  @VisibleForTesting
  static AndroidDataMerger createWithDefaultThreadPool(SourceChecker deDuplicator) {
    return new AndroidDataMerger(
        deDuplicator,
        MoreExecutors.newDirectExecutorService(),
        AndroidParsedDataDeserializer.create());
  }

  /** Creates a merger with a file contents hashing deduplicator. */
  static AndroidDataMerger createWithPathDeduplictor(
      ListeningExecutorService executorService,
      AndroidDataDeserializer deserializer,
      SourceChecker checker) {
    return new AndroidDataMerger(checker, executorService, deserializer);
  }

  private AndroidDataMerger(
      SourceChecker deDuplicator,
      ListeningExecutorService executorService,
      AndroidDataDeserializer deserializer) {
    this.deDuplicator = deDuplicator;
    this.executorService = executorService;
    this.deserializer = deserializer;
  }

  /**
   * Loads a list of dependency {@link SerializedAndroidData} and merge with the primary {@link
   * ParsedAndroidData}.
   *
   * @see AndroidDataMerger#merge(ParsedAndroidData, ParsedAndroidData, UnvalidatedAndroidData,
   *     boolean, boolean) for details.
   */
  UnwrittenMergedAndroidData loadAndMerge(
      List<? extends SerializedAndroidData> transitive,
      List<? extends SerializedAndroidData> direct,
      ParsedAndroidData primary,
      Path primaryManifest,
      boolean allowPrimaryOverrideAll,
      boolean throwOnResourceConflict) {
    Stopwatch timer = Stopwatch.createStarted();
    try {
      logger.fine(
          String.format("Merged dependencies read in %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
      timer.reset().start();
      return doMerge(
          ParsedAndroidData.loadedFrom(
              DependencyInfo.DependencyType.TRANSITIVE, transitive, executorService, deserializer),
          ParsedAndroidData.loadedFrom(
              DependencyInfo.DependencyType.DIRECT, direct, executorService, deserializer),
          primary,
          primaryManifest,
          allowPrimaryOverrideAll,
          throwOnResourceConflict);
    } finally {
      logger.fine(String.format("Resources merged in %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
    }
  }

  /**
   * Merges DataResources into an UnwrittenMergedAndroidData.
   *
   * <p>This method has two basic states, library and binary. These are distinguished by
   * allowPrimaryOverrideAll, which allows the primary data to overwrite any value in the closure, a
   * trait associated with binaries, as a binary is a leaf node. The other semantics are slightly
   * more complicated: a given resource can be overwritten only if it resides in the direct
   * dependencies of primary data. This forces an explicit simple priority for each resource,
   * instead of the more subtle semantics of multiple layers of libraries with potential overwrites.
   *
   * <p>The UnwrittenMergedAndroidData contains only one of each DataKey in both the direct and
   * transitive closure.
   *
   * <p>The merge semantics for overwriting resources (non id and styleable) are as follows:
   *
   * <pre>
   *   Key:
   *     A(): package A
   *     A(foo): package A with resource symbol foo
   *     A() -> B(): a dependency relationship of B.deps = [:A]
   *     A(),B() -> C(): a dependency relationship of C.deps = [:A,:B]
   *
   *   For android library (allowPrimaryOverrideAll = False)
   *
   *     A() -> B(foo) -> C(foo) == Valid
   *     A() -> B() -> C(foo) == Valid
   *     A() -> B() -> C(foo),D(foo) == Conflict
   *     A(foo) -> B(foo) -> C() == Conflict
   *     A(foo) -> B() -> C(foo) == Conflict
   *     A(foo),B(foo) -> C() -> D() == Conflict
   *     A() -> B(foo),C(foo) -> D() == Conflict
   *     A(foo),B(foo) -> C() -> D(foo) == Conflict
   *     A() -> B(foo),C(foo) -> D(foo) == Conflict
   *
   *   For android binary (allowPrimaryOverrideAll = True)
   *
   *     A() -> B(foo) -> C(foo) == Valid
   *     A() -> B() -> C(foo) == Valid
   *     A() -> B() -> C(foo),D(foo) == Conflict
   *     A(foo) -> B(foo) -> C() == Conflict
   *     A(foo) -> B() -> C(foo) == Valid
   *     A(foo),B(foo) -> C() -> D() == Conflict
   *     A() -> B(foo),C(foo) -> D() == Conflict
   *     A(foo),B(foo) -> C() -> D(foo) == Valid
   *     A() -> B(foo),C(foo) -> D(foo) == Valid
   * </pre>
   *
   * <p>Combining resources are much simpler -- since a combining (id and styleable) resource does
   * not get replaced when redefined, they are simply combined:
   *
   * <pre>
   *     A(foo) -> B(foo) -> C(foo) == Valid
   *
   * </pre>
   *
   * @param transitive The transitive dependencies to merge.
   * @param direct The direct dependencies to merge.
   * @param primaryData The primary data to merge against.
   * @param allowPrimaryOverrideAll Boolean that indicates if the primary data will be considered
   *     the ultimate source of truth, provided it doesn't conflict with itself.
   * @return An UnwrittenMergedAndroidData, containing DataResource objects that can be written to
   *     disk for aapt processing or serialized for future merge passes.
   * @throws MergingException if there are issues with parsing resources from primaryData.
   * @throws MergeConflictException if there are merge conflicts
   */
  UnwrittenMergedAndroidData merge(
      ParsedAndroidData transitive,
      ParsedAndroidData direct,
      UnvalidatedAndroidData primaryData,
      boolean allowPrimaryOverrideAll,
      boolean throwOnResourceConflict) {
    try {
      // Extract the primary resources.
      ParsedAndroidData parsedPrimary = ParsedAndroidData.from(primaryData);
      return doMerge(
          transitive,
          direct,
          parsedPrimary,
          primaryData.getManifest(),
          allowPrimaryOverrideAll,
          throwOnResourceConflict);
    } catch (IOException e) {
      throw MergingException.wrapException(e);
    }
  }

  UnwrittenMergedAndroidData doMerge(
      ParsedAndroidData transitive,
      ParsedAndroidData direct,
      ParsedAndroidData parsedPrimary,
      Path primaryManifest,
      boolean allowPrimaryOverrideAll,
      boolean throwOnResourceConflict) {

    // Create the builders for the final parsed data.
    final ParsedAndroidData.Builder primaryBuilder = ParsedAndroidData.Builder.newBuilder();
    final ParsedAndroidData.Builder transitiveBuilder = ParsedAndroidData.Builder.newBuilder();
    final KeyValueConsumers transitiveConsumers = transitiveBuilder.consumers();
    final KeyValueConsumers primaryConsumers = primaryBuilder.consumers();
    final Set<MergeConflict> conflicts = new LinkedHashSet<>();

    // Find all internal conflicts.
    conflicts.addAll(parsedPrimary.conflicts());
    for (MergeConflict conflict : Iterables.concat(direct.conflicts(), transitive.conflicts())) {
      if (allowPrimaryOverrideAll
          && (parsedPrimary.containsOverwritable(conflict.dataKey())
              || parsedPrimary.containsAsset(conflict.dataKey()))) {
        continue;
      }
      conflicts.add(conflict);
    }

    // overwriting resources
    for (Map.Entry<DataKey, DataResource> entry : parsedPrimary.iterateOverwritableEntries()) {
      if (direct.containsOverwritable(entry.getKey())) {
        primaryConsumers.overwritingConsumer.accept(
            entry.getKey(), entry.getValue().overwrite(direct.getOverwritable(entry.getKey())));
      } else {
        primaryConsumers.overwritingConsumer.accept(entry.getKey(), entry.getValue());
      }
    }

    for (Map.Entry<DataKey, DataResource> entry : direct.iterateOverwritableEntries()) {
      // Direct dependencies are simply overwritten, no conflict.
      if (!parsedPrimary.containsOverwritable(entry.getKey())) {
        transitiveConsumers.overwritingConsumer.accept(entry.getKey(), entry.getValue());
      }
    }
    for (Map.Entry<DataKey, DataResource> entry : transitive.iterateOverwritableEntries()) {
      // If the primary is considered to be intentional (usually at the binary level),
      // skip.
      if (allowPrimaryOverrideAll && parsedPrimary.containsOverwritable(entry.getKey())) {
        continue;
      }
      // If a transitive value is in the direct map, report a conflict, as it is commonly
      // unintentional.
      if (direct.containsOverwritable(entry.getKey())) {
        conflicts.add(direct.foundResourceConflict(entry.getKey(), entry.getValue()));
      } else if (parsedPrimary.containsOverwritable(entry.getKey())) {
        // If overwriting a transitive value with a primary map, assume it's an unintentional
        // override, unless allowPrimaryOverrideAll is set. At which point, this code path
        // should not be reached.
        conflicts.add(parsedPrimary.foundResourceConflict(entry.getKey(), entry.getValue()));
      } else {
        // If it's in none of the of sources, add it.
        transitiveConsumers.overwritingConsumer.accept(entry.getKey(), entry.getValue());
      }
    }

    // combining resources
    for (Map.Entry<DataKey, DataResource> entry : parsedPrimary.iterateCombiningEntries()) {
      primaryConsumers.combiningConsumer.accept(entry.getKey(), entry.getValue());
    }
    for (Map.Entry<DataKey, DataResource> entry : direct.iterateCombiningEntries()) {
      if (parsedPrimary.containsCombineable(entry.getKey())) {
        // If it is in the primary, add it to the primary to be combined.
        primaryConsumers.combiningConsumer.accept(entry.getKey(), entry.getValue());
      } else {
        // If the combining asset is not in the primary, put it into the transitive.
        transitiveConsumers.combiningConsumer.accept(entry.getKey(), entry.getValue());
      }
    }
    for (Map.Entry<DataKey, DataResource> entry : transitive.iterateCombiningEntries()) {
      if (parsedPrimary.containsCombineable(entry.getKey())) {
        primaryConsumers.combiningConsumer.accept(entry.getKey(), entry.getValue());
      } else {
        transitiveConsumers.combiningConsumer.accept(entry.getKey(), entry.getValue());
      }
    }

    // assets
    for (Map.Entry<DataKey, DataAsset> entry : parsedPrimary.iterateAssetEntries()) {
      if (direct.containsAsset(entry.getKey())) {
        primaryConsumers.assetConsumer.accept(
            entry.getKey(), entry.getValue().overwrite(direct.getAsset(entry.getKey())));
      } else {
        primaryConsumers.assetConsumer.accept(entry.getKey(), entry.getValue());
      }
    }

    for (Map.Entry<DataKey, DataAsset> entry : direct.iterateAssetEntries()) {
      // Direct dependencies are simply overwritten, no conflict.
      if (!parsedPrimary.containsAsset(entry.getKey())) {
        transitiveConsumers.assetConsumer.accept(entry.getKey(), entry.getValue());
      }
    }
    for (Map.Entry<DataKey, DataAsset> entry : transitive.iterateAssetEntries()) {
      // If the primary is considered to be intentional (usually at the binary level),
      // skip.
      if (allowPrimaryOverrideAll && parsedPrimary.containsAsset(entry.getKey())) {
        continue;
      }
      // If a transitive value is in the direct map report a conflict, as it is commonly
      // unintentional.
      if (direct.containsAsset(entry.getKey())) {
        conflicts.add(direct.foundAssetConflict(entry.getKey(), entry.getValue()));
      } else if (parsedPrimary.containsAsset(entry.getKey())) {
        // If overwriting a transitive value with a primary map, assume it's an unintentional
        // override, unless allowPrimaryOverrideAll is set. At which point, this code path
        // should not be reached.
        conflicts.add(parsedPrimary.foundAssetConflict(entry.getKey(), entry.getValue()));
      } else {
        // If it's in none of the of sources, add it.
        transitiveConsumers.assetConsumer.accept(entry.getKey(), entry.getValue());
      }
    }
    final UnwrittenMergedAndroidData unwrittenMergedAndroidData = UnwrittenMergedAndroidData.of(
        primaryManifest, primaryBuilder.build(), transitiveBuilder.build(), conflicts);

    try {
      List<String> messages = unwrittenMergedAndroidData
          .asConflictMessagesIfValidWith(deDuplicator);

      if (!messages.isEmpty()) {
        if (throwOnResourceConflict) {
          throw MergeConflictException.withMessage(Joiner.on("\n").join(messages));
        } else {
          logger.warning(Joiner.on("\n").join(messages));
        }
      }
    } catch (IOException e) {
      throw MergingException.wrapException(e);
    }

    return unwrittenMergedAndroidData;
  }
}
