// Copyright 2015 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.lib.rules.objc;

import static com.google.devtools.build.lib.rules.objc.ObjcProvider.ASSET_CATALOG;
import static com.google.devtools.build.lib.rules.objc.ObjcProvider.BUNDLE_FILE;
import static com.google.devtools.build.lib.rules.objc.ObjcProvider.STRINGS;
import static com.google.devtools.build.lib.rules.objc.ObjcProvider.XCASSETS_DIR;

import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.base.Verify;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.analysis.FilesToRunProvider;
import com.google.devtools.build.lib.analysis.RuleConfiguredTarget.Mode;
import com.google.devtools.build.lib.analysis.RuleContext;
import com.google.devtools.build.lib.analysis.actions.BinaryFileWriteAction;
import com.google.devtools.build.lib.analysis.actions.CommandLine;
import com.google.devtools.build.lib.analysis.actions.CustomCommandLine;
import com.google.devtools.build.lib.analysis.actions.SpawnAction;
import com.google.devtools.build.lib.collect.nestedset.NestedSet;
import com.google.devtools.build.lib.rules.apple.AppleConfiguration;
import com.google.devtools.build.lib.rules.apple.AppleToolchain;
import com.google.devtools.build.lib.rules.apple.Platform;
import com.google.devtools.build.lib.rules.apple.Platform.PlatformType;
import com.google.devtools.build.lib.rules.objc.XcodeProvider.Builder;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.PathFragment;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

/**
 * Support for generating iOS bundles which contain metadata (a plist file), assets, resources and
 * optionally a binary: registers actions that assemble resources and merge plists, provides data
 * to providers and validates bundle-related attributes.
 *
 * <p>Methods on this class can be called in any order without impacting the result.
 */
final class BundleSupport {

  /**
   * Iterable wrapper used to strongly type arguments eventually passed to {@code actool}.
   */
  static final class ExtraActoolArgs extends IterableWrapper<String> {
    ExtraActoolArgs(Iterable<String> args) {
      super(args);
    }

    ExtraActoolArgs(String... args) {
      super(args);
    }
  }

  // TODO(cparsons): Take restricted interfaces of RuleContext instead of RuleContext (such as
  // RuleErrorConsumer).
  private final RuleContext ruleContext;
  private final AppleConfiguration appleConfiguration;
  private final Platform platform;
  private final ExtraActoolArgs extraActoolArgs;
  private final Bundling bundling;
  private final Attributes attributes;

  /**
   * Creates a new bundle support.
   *
   * @param ruleContext context this bundle is constructed in
   * @param appleConfiguration the configuration this bundle is constructed in
   * @param platform the platform this bundle is built for
   * @param bundling bundle information as configured for this rule
   * @param extraActoolArgs any additional parameters to be used for invoking {@code actool}
   */
  public BundleSupport(RuleContext ruleContext, AppleConfiguration appleConfiguration,
      Platform platform, Bundling bundling, ExtraActoolArgs extraActoolArgs) {
    this.ruleContext = ruleContext;
    this.appleConfiguration = appleConfiguration;
    this.platform = platform;
    this.extraActoolArgs = extraActoolArgs;
    this.bundling = bundling;
    this.attributes = new Attributes(ruleContext);
  }

  /**
   * Registers actions required for constructing this bundle, namely merging all involved {@code
   * Info.plist} files and generating asset catalogues.
   *
   * @param objcProvider source of information from this rule's attributes and its dependencies
   * @return this bundle support
   */
  BundleSupport registerActions(ObjcProvider objcProvider) {
    registerConvertStringsActions(objcProvider);
    registerConvertXibsActions(objcProvider);
    registerMomczipActions(objcProvider);
    registerInterfaceBuilderActions(objcProvider);
    registerActoolActionIfNecessary(objcProvider);

    if (bundling.needsToMergeInfoplist()) {
      NestedSet<Artifact> mergingContentArtifacts = bundling.getMergingContentArtifacts();
      Artifact mergedPlist = bundling.getBundleInfoplist().get();
      registerMergeInfoplistAction(
          mergingContentArtifacts, PlMergeControlBytes.fromBundling(bundling, mergedPlist));
    }
    return this;
  }

  /**
   * Adds any Xcode settings related to this bundle to the given provider builder.
   *
   * @return this bundle support
   */
  BundleSupport addXcodeSettings(Builder xcodeProviderBuilder) {
    if (bundling.getBundleInfoplist().isPresent()) {
      xcodeProviderBuilder.setBundleInfoplist(bundling.getBundleInfoplist().get());
    }
    return this;
  }

  private void validatePlatform() {
    Platform platform = null;
    for (String architecture : appleConfiguration.getIosMultiCpus()) {
      if (platform == null) {
        platform = Platform.forTarget(PlatformType.IOS, architecture);
      } else if (platform != Platform.forTarget(PlatformType.IOS, architecture)) {
        ruleContext.ruleError(
            String.format("In builds which require bundling, --ios_multi_cpus does not currently "
                + "allow values for both simulator and device builds. Flag was %s",
                appleConfiguration.getIosMultiCpus()));
      }
    }
  }

  private void validateResources(ObjcProvider objcProvider) {
    Map<String, Artifact> bundlePathToFile = new HashMap<>();
    NestedSet<Artifact> artifacts = objcProvider.get(STRINGS);

    Iterable<BundleableFile> bundleFiles =
        Iterables.concat(
            objcProvider.get(BUNDLE_FILE), BundleableFile.flattenedRawResourceFiles(artifacts));
    for (BundleableFile bundleFile : bundleFiles) {
      String bundlePath = bundleFile.getBundlePath();
      Artifact bundled = bundleFile.getBundled();

      // Normally any two resources mapped to the same path in the bundle are illegal. However, we
      // currently don't have a good solution for resources generated by a genrule in
      // multi-architecture builds: They map to the same bundle path but have different owners (the
      // genrules targets in the various configurations) and roots (one for each architecture).
      // Since we know that architecture shouldn't matter for strings file generation we silently
      // ignore cases like this and pick one of the outputs at random to put in the bundle (see also
      // related filtering code in Bundling.Builder.build()).
      if (bundlePathToFile.containsKey(bundlePath)) {
        Artifact original = bundlePathToFile.get(bundlePath);
        if (!Objects.equals(original.getOwner(), bundled.getOwner())) {
          ruleContext.ruleError(
              String.format(
                  "Two files map to the same path [%s] in this bundle but come from different "
                      + "locations: %s and %s",
                  bundlePath,
                  original.getOwner(),
                  bundled.getOwner()));
        } else {
          Verify.verify(
              !original.getRoot().equals(bundled.getRoot()),
              "%s and %s should have different roots but have %s and %s",
              original,
              bundleFile,
              original.getRoot(),
              bundled.getRoot());
        }

      } else {
        bundlePathToFile.put(bundlePath, bundled);
      }
    }

    // TODO(bazel-team): Do the same validation for storyboards and datamodels which could also be
    // generated by genrules or doubly defined.
  }

  /**
   * Validates bundle support.
   * <ul>
   * <li>Validates that resources defined in this rule and its dependencies and written to this
   *     bundle are legal (for example that they are not mapped to the same bundle location)
   * <li>Validates the platform for this build is either simulator or device, and does not
   *     contain architectures for both platforms
   * </ul>
   *
   * @return this bundle support
   */
  BundleSupport validate(ObjcProvider objcProvider) {
    validatePlatform();
    validateResources(objcProvider);

    return this;
  }

  /**
   * Returns a set containing the {@link TargetDeviceFamily} values which this bundle is targeting.
   * Returns an empty set for any invalid value of the target device families attribute.
   */
  ImmutableSet<TargetDeviceFamily> targetDeviceFamilies() {
    return bundling.getTargetDeviceFamilies();
  }
 
  /**
   * Returns true if this bundle is targeted to {@link TargetDeviceFamily#WATCH}, false otherwise.
   */
  boolean isBuildingForWatch() {
    return Iterables.any(targetDeviceFamilies(),
        new Predicate<TargetDeviceFamily>() {
      @Override
      public boolean apply(TargetDeviceFamily targetDeviceFamily) {
        return targetDeviceFamily.name().equalsIgnoreCase(TargetDeviceFamily.WATCH.getNameInRule());
      }
    });
  }

  /**
   * Returns a set containing the {@link TargetDeviceFamily} values the resources in this bundle
   * are targeting. When watch is included as one of the families, (for example [iphone, watch] for
   * simulator builds, assets should always be compiled for {@link TargetDeviceFamily#WATCH}.
   */
  private ImmutableSet<TargetDeviceFamily> targetDeviceFamiliesForResources() {
    if (isBuildingForWatch()) {
      return ImmutableSet.of(TargetDeviceFamily.WATCH);
    } else {
      return targetDeviceFamilies();
    }
  }

  private void registerInterfaceBuilderActions(ObjcProvider objcProvider) {
    for (Artifact storyboardInput : objcProvider.get(ObjcProvider.STORYBOARD)) {
      String archiveRoot = storyboardArchiveRoot(storyboardInput);
      Artifact zipOutput = bundling.getIntermediateArtifacts()
          .compiledStoryboardZip(storyboardInput);

      ruleContext.registerAction(
          ObjcRuleClasses.spawnAppleEnvActionBuilder(appleConfiguration, platform)
              .setMnemonic("StoryboardCompile")
              .setExecutable(attributes.ibtoolWrapper())
              .setCommandLine(ibActionsCommandLine(archiveRoot, zipOutput, storyboardInput))
              .addOutput(zipOutput)
              .addInput(storyboardInput)
              .build(ruleContext));
    }
  }

  /**
   * Returns the root file path to which storyboard interfaces are compiled.
   */
  protected String storyboardArchiveRoot(Artifact storyboardInput) {
    // When storyboards are compiled for {@link TargetDeviceFamily#WATCH}, return the containing
    // directory if it ends with .lproj to account for localization or "." representing the bundle
    // root otherwise. Examples: Payload/Foo.app/Base.lproj/<compiled_file>,
    // Payload/Foo.app/<compile_file_1>
    if (isBuildingForWatch()) {
      String containingDir = storyboardInput.getExecPath().getParentDirectory().getBaseName();
      return containingDir.endsWith(".lproj") ? (containingDir + "/") : ".";
    } else {
      return BundleableFile.flatBundlePath(storyboardInput.getExecPath()) + "c";
    }
  }

  private CommandLine ibActionsCommandLine(String archiveRoot, Artifact zipOutput,
      Artifact storyboardInput) {
    CustomCommandLine.Builder commandLine =
        CustomCommandLine.builder()
            // The next three arguments are positional, i.e. they don't have flags before them.
            .addPath(zipOutput.getExecPath())
            .add(archiveRoot)
            .add("--minimum-deployment-target")
            .add(bundling.getMinimumOsVersion().toString())
            .add("--module")
            .add(ruleContext.getLabel().getName());

    for (TargetDeviceFamily targetDeviceFamily : targetDeviceFamiliesForResources()) {
      commandLine.add("--target-device").add(targetDeviceFamily.name().toLowerCase(Locale.US));
    }

    return commandLine
        .addPath(storyboardInput.getExecPath())
        .build();
  }

  private void registerMomczipActions(ObjcProvider objcProvider) {
    Iterable<Xcdatamodel> xcdatamodels = Xcdatamodels.xcdatamodels(
        bundling.getIntermediateArtifacts(), objcProvider.get(ObjcProvider.XCDATAMODEL));
    for (Xcdatamodel datamodel : xcdatamodels) {
      Artifact outputZip = datamodel.getOutputZip();
      ruleContext.registerAction(
          ObjcRuleClasses.spawnAppleEnvActionBuilder(appleConfiguration, platform)
              .setMnemonic("MomCompile")
              .setExecutable(attributes.momcWrapper())
              .addOutput(outputZip)
              .addInputs(datamodel.getInputs())
              .setCommandLine(CustomCommandLine.builder()
                  .addPath(outputZip.getExecPath())
                  .add(datamodel.archiveRootForMomczip())
                  .add("-XD_MOMC_SDKROOT=" + AppleToolchain.sdkDir())
                  .add("-XD_MOMC_IOS_TARGET_VERSION=" + bundling.getMinimumOsVersion())
                  .add("-MOMC_PLATFORMS")
                  .add(appleConfiguration.getMultiArchPlatform(PlatformType.IOS)
                      .getLowerCaseNameInPlist())
                  .add("-XD_MOMC_TARGET_VERSION=10.6")
                  .add(datamodel.getContainer().getSafePathString())
                  .build())
              .build(ruleContext));
    }
  }

  private void registerConvertXibsActions(ObjcProvider objcProvider) {
    for (Artifact original : objcProvider.get(ObjcProvider.XIB)) {
      Artifact zipOutput = bundling.getIntermediateArtifacts().compiledXibFileZip(original);
      String archiveRoot = BundleableFile.flatBundlePath(
          FileSystemUtils.replaceExtension(original.getExecPath(), ".nib"));

      ruleContext.registerAction(
          ObjcRuleClasses.spawnAppleEnvActionBuilder(appleConfiguration, platform)
              .setMnemonic("XibCompile")
              .setExecutable(attributes.ibtoolWrapper())
              .setCommandLine(ibActionsCommandLine(archiveRoot, zipOutput, original))
              .addOutput(zipOutput)
              .addInput(original)
              // Disable sandboxing due to Bazel issue #2189.
              .disableSandboxing()
              .build(ruleContext));
    }
  }

  private void registerConvertStringsActions(ObjcProvider objcProvider) {
    for (Artifact strings : objcProvider.get(ObjcProvider.STRINGS)) {
      Artifact bundled = bundling.getIntermediateArtifacts().convertedStringsFile(strings);
      ruleContext.registerAction(
          ObjcRuleClasses.spawnAppleEnvActionBuilder(appleConfiguration, platform)
              .setMnemonic("ConvertStringsPlist")
              .setExecutable(new PathFragment("/usr/bin/plutil"))
              .setCommandLine(CustomCommandLine.builder()
                  .add("-convert").add("binary1")
                  .addExecPath("-o", bundled)
                  .add("--")
                  .addPath(strings.getExecPath())
                  .build())
              .addInput(strings)
              .addInput(CompilationSupport.xcrunwrapper(ruleContext).getExecutable())
              .addOutput(bundled)
              .build(ruleContext));
    }
  }

  /**
   * Creates action to merge multiple Info.plist files of a bundle into a single Info.plist. The
   * merge action is necessary if there are more than one input plist files or we have a bundle ID
   * to stamp on the merged plist.
   */
  private void registerMergeInfoplistAction(
      NestedSet<Artifact> mergingContentArtifacts, PlMergeControlBytes controlBytes) {
    if (!bundling.needsToMergeInfoplist()) {
      return; // Nothing to do here.
    }

    Artifact plMergeControlArtifact = baseNameArtifact(ruleContext, ".plmerge-control");

    ruleContext.registerAction(
        new BinaryFileWriteAction(
            ruleContext.getActionOwner(),
            plMergeControlArtifact,
            controlBytes,
            /*makeExecutable=*/ false));

    ruleContext.registerAction(
        new SpawnAction.Builder()
            .setMnemonic("MergeInfoPlistFiles")
            .setExecutable(attributes.plmerge())
            .addArgument("--control")
            .addInputArgument(plMergeControlArtifact)
            .addTransitiveInputs(mergingContentArtifacts)
            .addOutput(bundling.getIntermediateArtifacts().mergedInfoplist())
            .build(ruleContext));
  }

  /**
   * Returns an {@link Artifact} with name prefixed with prefix given in {@link Bundling} if
   * available. This helps in creating unique artifact name when multiple bundles are created
   * with a different name than the target name.
   */
  private Artifact baseNameArtifact(RuleContext ruleContext, String artifactName) {
    String prefixedArtifactName;
    if (bundling.getArtifactPrefix() != null) {
      prefixedArtifactName = String.format("-%s%s", bundling.getArtifactPrefix(), artifactName);
    } else {
      prefixedArtifactName = artifactName;
    }
    return ObjcRuleClasses.artifactByAppendingToBaseName(ruleContext, prefixedArtifactName);
  }

  private void registerActoolActionIfNecessary(ObjcProvider objcProvider) {
    Optional<Artifact> actoolzipOutput = bundling.getActoolzipOutput();
    if (!actoolzipOutput.isPresent()) {
      return;
    }

    Artifact actoolPartialInfoplist = actoolPartialInfoplist(objcProvider).get();
    Artifact zipOutput = actoolzipOutput.get();

    // TODO(bazel-team): Do not use the deploy jar explicitly here. There is currently a bug where
    // we cannot .setExecutable({java_binary target}) and set REQUIRES_DARWIN in the execution info.
    // Note that below we set the archive root to the empty string. This means that the generated
    // zip file will be rooted at the bundle root, and we have to prepend the bundle root to each
    // entry when merging it with the final .ipa file.
    ruleContext.registerAction(
        ObjcRuleClasses.spawnAppleEnvActionBuilder(appleConfiguration, platform)
            .setMnemonic("AssetCatalogCompile")
            .setExecutable(attributes.actoolWrapper())
            .addTransitiveInputs(objcProvider.get(ASSET_CATALOG))
            .addOutput(zipOutput)
            .addOutput(actoolPartialInfoplist)
            .setCommandLine(actoolzipCommandLine(
                objcProvider,
                zipOutput,
                actoolPartialInfoplist))
            .disableSandboxing()
            .build(ruleContext));
  }

  private CommandLine actoolzipCommandLine(ObjcProvider provider, Artifact zipOutput,
      Artifact partialInfoPlist) {
    PlatformType platformType = PlatformType.IOS;
    // watchOS 1 and 2 use different platform arguments. It is likely that versions 2 and later will
    // use the watchos platform whereas watchOS 1 uses the iphone platform.
    if (isBuildingForWatch() && bundling.getBundleDir().startsWith("Watch")) {
      platformType = PlatformType.WATCHOS;
    }
    CustomCommandLine.Builder commandLine =
        CustomCommandLine.builder()
            // The next three arguments are positional, i.e. they don't have flags before them.
            .addPath(zipOutput.getExecPath())
            .add("--platform")
            .add(appleConfiguration.getMultiArchPlatform(platformType)
                .getLowerCaseNameInPlist())
            .addExecPath("--output-partial-info-plist", partialInfoPlist)
            .add("--minimum-deployment-target")
            .add(bundling.getMinimumOsVersion().toString());

    for (TargetDeviceFamily targetDeviceFamily : targetDeviceFamiliesForResources()) {
      commandLine.add("--target-device").add(targetDeviceFamily.name().toLowerCase(Locale.US));
    }

    return commandLine
        .add(PathFragment.safePathStrings(provider.get(XCASSETS_DIR)))
        .add(extraActoolArgs)
        .build();
  }


  /**
   * Returns the artifact that is a plist file generated by an invocation of {@code actool} or
   * {@link Optional#absent()} if no asset catalogues are present in this target and its
   * dependencies.
   *
   * <p>All invocations of {@code actool} generate this kind of plist file, which contains metadata
   * about the {@code app_icon} and {@code launch_image} if supplied. If neither an app icon or a
   * launch image was supplied, the plist file generated is empty.
   */
  private Optional<Artifact> actoolPartialInfoplist(ObjcProvider objcProvider) {
    if (objcProvider.hasAssetCatalogs()) {
      return Optional.of(bundling.getIntermediateArtifacts().actoolPartialInfoplist());
    } else {
      return Optional.absent();
    }
  }

  /**
   * Common rule attributes used by a bundle support.
   */
  private static class Attributes {
    private final RuleContext ruleContext;

    private Attributes(RuleContext ruleContext) {
      this.ruleContext = ruleContext;
    }

    /**
     * Returns a reference to the plmerge executable.
     */
    FilesToRunProvider plmerge() {
      return ruleContext.getExecutablePrerequisite("$plmerge", Mode.HOST);
    }

    /**
     * Returns the location of the ibtoolwrapper tool.
     */
    FilesToRunProvider ibtoolWrapper() {
      return ruleContext.getExecutablePrerequisite("$ibtoolwrapper", Mode.HOST);
    }

    /**
     * Returns the location of the momcwrapper.
     */
    FilesToRunProvider momcWrapper() {
      return ruleContext.getExecutablePrerequisite("$momcwrapper", Mode.HOST);
    }

    /**
     * Returns the location of the actoolwrapper.
     */
    FilesToRunProvider actoolWrapper() {
      return ruleContext.getExecutablePrerequisite("$actoolwrapper", Mode.HOST);
    }
  }
}
