// Copyright 2014 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.xcode.plmerge;

import com.dd.plist.BinaryPropertyListWriter;
import com.dd.plist.NSDictionary;
import com.dd.plist.NSObject;
import com.dd.plist.NSString;
import com.dd.plist.PropertyListFormatException;
import com.dd.plist.PropertyListParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.io.ByteSource;
import com.google.devtools.build.xcode.plmerge.proto.PlMergeProtos.Control;
import com.google.devtools.build.xcode.util.Equaling;
import com.google.devtools.build.xcode.util.Mapping;
import com.google.devtools.build.xcode.util.Value;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.ParseException;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.SAXException;

/**
 * Utility code for merging project files.
 */
public class PlistMerging extends Value<PlistMerging> {
  private static final String BUNDLE_IDENTIFIER_PLIST_KEY = "CFBundleIdentifier";
  private static final String BUNDLE_IDENTIFIER_DEFAULT = "com.generic.bundleidentifier";
  private static final String BUNDLE_VERSION_PLIST_KEY = "CFBundleVersion";
  private static final String BUNDLE_VERSION_DEFAULT = "1.0.0";
  private static final String BUNDLE_SHORT_VERSION_STRING_PLIST_KEY = "CFBundleShortVersionString";
  private static final String BUNDLE_SHORT_VERSION_STRING_DEFAULT = "1.0";

  /**
   * Exception type thrown when validation of the plist file fails.
   */
  public static class ValidationException extends RuntimeException {
    ValidationException(String message) {
      super(message);
    }
  }

  private final NSDictionary merged;

  /**
   * Wraps a {@code NSDictionary} as a PlistMerging.
   */
  public PlistMerging(NSDictionary merged) {
    super(merged);
    this.merged = merged;
  }

  /**
   * Merges several plist files into a single {@code NSDictionary}. Each file should be a plist (of
   * one of these formats: ASCII, Binary, or XML) that contains an NSDictionary.
   */
  @VisibleForTesting
  static NSDictionary merge(Collection<? extends Path> sourceFilePaths) throws IOException {
    NSDictionary result = new NSDictionary();
    for (Path sourceFilePath : sourceFilePaths) {
      result.putAll(readPlistFile(sourceFilePath));
    }
    return result;
  }

  public static NSDictionary readPlistFile(final Path sourceFilePath) throws IOException {
    ByteSource rawBytes = new Utf8BomSkippingByteSource(sourceFilePath);

    try {
      try (InputStream in = rawBytes.openStream()) {
        return (NSDictionary) PropertyListParser.parse(in);
      } catch (PropertyListFormatException | ParseException e) {
        // If we failed to parse, the plist may implicitly be a map. To handle this, wrap the plist
        // with {}.
        // TODO(bazel-team): Do this in a cleaner way.
        ByteSource concatenated = ByteSource.concat(
            ByteSource.wrap(new byte[] {'{'}),
            rawBytes,
            ByteSource.wrap(new byte[] {'}'}));
        try (InputStream in = concatenated.openStream()) {
          return (NSDictionary) PropertyListParser.parse(in);
        }
      }
    } catch (PropertyListFormatException | ParseException | ParserConfigurationException
        | SAXException e) {
      throw new IOException(e);
    }
  }

  /**
   * Writes the results of a merge operation to a binary plist file.
   * @param plistPath the path of the plist to write in binary format
   */
  public PlistMerging writePlist(Path plistPath) throws IOException {
    try (OutputStream out = Files.newOutputStream(plistPath)) {
      BinaryPropertyListWriter.write(out, merged);
    }
    return this;
  }

  /**
   * Writes the results of a merge operation to an XML plist file.
   * @param plistPath the path of the plist to write in XML format
   */
  public PlistMerging writeXmlPlist(Path plistPath) throws IOException {
    try (OutputStream out = Files.newOutputStream(plistPath)) {
      PropertyListParser.saveAsXML(merged, out);
    }
    return this;
  }

  /**
   * Writes a PkgInfo file based on certain keys in the merged plist.
   * @param pkgInfoPath the path of the PkgInfo file to write. In many iOS apps, this file just
   *     contains the raw string {@code APPL????}.
   */
  public PlistMerging writePkgInfo(Path pkgInfoPath) throws IOException {
    String pkgInfo =
        Mapping.of(merged, "CFBundlePackageType").or(NSObject.wrap("APPL")).toString()
        + Mapping.of(merged, "CFBundleSignature").or(NSObject.wrap("????")).toString();
    Files.write(pkgInfoPath, pkgInfo.getBytes(StandardCharsets.UTF_8));
    return this;
  }

  /**
   * Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and
   * modifying them based on substitutions and keysToRemoveIfEmptyString.
   */
  public static PlistMerging from(
      Control control,
      KeysToRemoveIfEmptyString keysToRemoveIfEmptyString)
      throws IOException {

    FileSystem fileSystem = FileSystems.getDefault();

    ImmutableList.Builder<Path> sourceFilePathsBuilder = new ImmutableList.Builder<>();
    for (String pathString : control.getSourceFileList()) {
      sourceFilePathsBuilder.add(fileSystem.getPath(pathString));
    }
    ImmutableList.Builder<Path> immutableSourceFilePathsBuilder = new ImmutableList.Builder<>();
    for (String pathString : control.getImmutableSourceFileList()) {
      immutableSourceFilePathsBuilder.add(fileSystem.getPath(pathString));
    }

    return from(
        sourceFilePathsBuilder.build(),
        immutableSourceFilePathsBuilder.build(),
        control.getVariableSubstitutionMap(),
        keysToRemoveIfEmptyString,
        Strings.emptyToNull(control.getExecutableName()));
  }

  /**
   * Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and
   * modifying them based on subsitutions and keysToRemoveIfEmptyString.
   */
  public static PlistMerging from(
      List<Path> sourceFiles,
      List<Path> immutableSourceFiles,
      Map<String, String> substitutions,
      KeysToRemoveIfEmptyString keysToRemoveIfEmptyString,
      String executableName)
      throws IOException {
    NSDictionary merged = PlistMerging.merge(sourceFiles);
    NSDictionary immutableEntries = PlistMerging.merge(immutableSourceFiles);
    Set<String> conflictingEntries = Sets.intersection(immutableEntries.keySet(), merged.keySet());

    Preconditions.checkArgument(
        conflictingEntries.isEmpty(),
        "The following plist entries may not be overridden, but are present in more than one "
            + "of the input lists: %s",
        conflictingEntries);
    merged.putAll(immutableEntries);

    for (Map.Entry<String, NSObject> entry : merged.entrySet()) {
      if (entry.getValue().toJavaObject() instanceof String) {
        String newValue = substituteEnvironmentVariable(
            substitutions, (String) entry.getValue().toJavaObject());
        merged.put(entry.getKey(), newValue);
      }
    }

    for (String key : keysToRemoveIfEmptyString) {
      if (Equaling.of(Mapping.of(merged, key), Optional.<NSObject>of(new NSString("")))) {
        merged.remove(key);
      }
    }

    // Info.plist files must contain a valid CFBundleVersion and a valid CFBundleShortVersionString,
    // or it will be rejected by Apple.
    // A valid Bundle Version is 18 characters or less, and only contains [0-9.]
    // We know we have an info.plist file as opposed to a strings file if the immutableEntries
    // have any values set.
    // TODO(bazel-team): warn user if we replace their values.
    if (!immutableEntries.isEmpty()) {
      Pattern versionPattern = Pattern.compile("[^0-9.]");
      if (!merged.containsKey(BUNDLE_VERSION_PLIST_KEY)) {
        merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT);
      } else {
        NSObject nsVersion = merged.get(BUNDLE_VERSION_PLIST_KEY);
        String version = (String) nsVersion.toJavaObject();
        if (version.length() > 18 || versionPattern.matcher(version).find()) {
          merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT);
        }
      }
      if (!merged.containsKey(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY)) {
        merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT);
      } else {
        NSObject nsVersion = merged.get(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY);
        String version = (String) nsVersion.toJavaObject();
        if (version.length() > 18 || versionPattern.matcher(version).find()) {
          merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT);
        }
      }
    }
    
    PlistMerging result = new PlistMerging(merged);

    if (executableName != null) {
      result.setExecutableName(executableName);
    }

    return result;
  }

  private static String substituteEnvironmentVariable(
      Map<String, String> substitutions, String string) {
    // The substitution is *not* performed recursively.
    for (Map.Entry<String, String> variable : substitutions.entrySet()) {
      String key = variable.getKey();
      String value = variable.getValue();
      string = string
          .replace("${" + key + "}", value)
          .replace("$(" + key + ")", value);
      key = key + ":rfc1034identifier";
      value = convertToRFC1034(value);
      string = string
          .replace("${" + key + "}", value)
          .replace("$(" + key + ")", value);
    }

    return string;
  }

  // Force RFC1034 compliance by changing any "bad" character to a '-'
  // This is essentially equivalent to what Xcode does.
  private static String convertToRFC1034(String value) {
    return value.replaceAll("[^-0-9A-Za-z.]", "-");
  }

  @VisibleForTesting
  NSDictionary asDictionary() {
    return merged;
  }

  /**
   * Sets the given executable name on this merged plist in the {@code CFBundleExecutable}
   * attribute.
   *
   * @param executableName name of the bundle executable
   * @return this plist merging
   * @throws ValidationException if the plist already contains an incompatible
   *    {@code CFBundleExecutable} entry
   */
  public PlistMerging setExecutableName(String executableName) {
    NSString bundleExecutable = (NSString) merged.get("CFBundleExecutable");

    if (bundleExecutable == null) {
      merged.put("CFBundleExecutable", executableName);
    } else if (!executableName.equals(bundleExecutable.getContent())) {
      throw new ValidationException(
          String.format(
              "Bazel generated the executable %s but the Plist CFBundleExecutable is %s",
              executableName, bundleExecutable));
    }

    return this;
  }
  
  /**
   * Sets the given identifier on this merged plist in the {@code CFBundleIdentifier}
   * attribute.
   *
   * @param primaryIdentifier used to set the bundle identifier or override the existing one from
   *     plist file, can be null
   * @param fallbackIdentifier used to set the bundle identifier if it is not set by plist file or
   *     primary identifier, can be null
   * @return this plist merging
   */
  public PlistMerging setBundleIdentifier(String primaryIdentifier, String fallbackIdentifier) {
    NSString bundleIdentifier = (NSString) merged.get(BUNDLE_IDENTIFIER_PLIST_KEY);

    if (primaryIdentifier != null) {
      merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(primaryIdentifier));
    } else if (bundleIdentifier == null) {
      if (fallbackIdentifier != null) {
        merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(fallbackIdentifier));
      } else {
        // TODO(bazel-team): We shouldn't be generating an info.plist in this case.
        merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, BUNDLE_IDENTIFIER_DEFAULT);
      }
    }

    return this;
  }

  private static class Utf8BomSkippingByteSource extends ByteSource {

    private static final byte[] UTF8_BOM =
        new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF };

    private final Path path;

    public Utf8BomSkippingByteSource(Path path) {
      this.path = path;
    }

    @Override
    public InputStream openStream() throws IOException {
      InputStream stream = new BufferedInputStream(Files.newInputStream(path));
      stream.mark(UTF8_BOM.length);
      byte[] buffer = new byte[UTF8_BOM.length];
      int read = stream.read(buffer);
      stream.reset();

      if (UTF8_BOM.length == read && Arrays.equals(buffer, UTF8_BOM)) {
        stream.skip(UTF8_BOM.length);
      }

      return stream;
    }
  }
}
