/*
 * Copyright 2016 Google LLC.
 *
 * 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.cloud.tools.appengine.operations;

import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;

import com.google.cloud.tools.appengine.AppEngineException;
import com.google.cloud.tools.appengine.configuration.AppYamlProjectStageConfiguration;
import com.google.cloud.tools.io.FileUtil;
import com.google.cloud.tools.project.AppYaml;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.logging.Logger;
import javax.annotation.Nullable;

/** Application stager for app.yaml based applications before deployment. */
public class AppYamlProjectStaging {

  private static final Logger log = Logger.getLogger(AppYamlProjectStaging.class.getName());

  private static final String APP_YAML = "app.yaml";

  @VisibleForTesting
  static final ImmutableList<String> OTHER_YAMLS =
      ImmutableList.of("cron.yaml", "dos.yaml", "dispatch.yaml", "index.yaml", "queue.yaml");

  /**
   * Stages an app.yaml based App Engine project for deployment. Copies app.yaml, the project
   * artifact and any user defined extra files. Will also copy the Docker directory for flex
   * projects.
   *
   * @param config Specifies artifacts and staging destination
   * @throws AppEngineException When staging fails
   */
  public void stageArchive(AppYamlProjectStageConfiguration config) throws AppEngineException {
    Preconditions.checkNotNull(config);
    Path stagingDirectory = config.getStagingDirectory();

    if (!Files.exists(stagingDirectory)) {
      throw new AppEngineException(
          "Staging directory does not exist. Location: " + stagingDirectory);
    }
    if (!Files.isDirectory(stagingDirectory)) {
      throw new AppEngineException(
          "Staging location is not a directory. Location: " + stagingDirectory);
    }

    try {
      String env = findEnv(config);
      String runtime = findRuntime(config);
      if ("flex".equals(env)) {
        stageFlexibleArchive(config, runtime);
        return;
      }
      if ("java11".equals(runtime)) {
        boolean isJar = config.getArtifact().getFileName().toString().endsWith(".jar");
        if (isJar) {
          stageStandardArchive(config);
          return;
        }
        if (hasCustomEntrypoint(config)) {
          stageStandardBinary(config);
          return;
        }
        // I cannot deploy non-jars without custom entrypoints
        throw new AppEngineException(
            "Cannot process application with runtime: java11."
                + " A custom entrypoint must be defined in your app.yaml for non-jar artifact: "
                + config.getArtifact().toString());
      }
      // I don't know how to deploy this
      throw new AppEngineException(
          "Cannot process application with runtime: "
              + runtime
              + (Strings.isNullOrEmpty(env) ? "" : " and env: " + env));
    } catch (IOException ex) {
      throw new AppEngineException(ex);
    }
  }

  @VisibleForTesting
  void stageFlexibleArchive(AppYamlProjectStageConfiguration config, @Nullable String runtime)
      throws IOException, AppEngineException {
    CopyService copyService = new CopyService();
    copyDockerContext(config, copyService, runtime);
    copyExtraFiles(config, copyService);
    copyAppEngineContext(config, copyService);
    copyArtifact(config, copyService);
  }

  @VisibleForTesting
  void stageStandardArchive(AppYamlProjectStageConfiguration config)
      throws IOException, AppEngineException {
    CopyService copyService = new CopyService();
    copyExtraFiles(config, copyService);
    copyAppEngineContext(config, copyService);
    copyArtifact(config, copyService);
    copyArtifactJarClasspath(config, copyService);
  }

  @VisibleForTesting
  void stageStandardBinary(AppYamlProjectStageConfiguration config)
      throws IOException, AppEngineException {
    CopyService copyService = new CopyService();
    copyExtraFiles(config, copyService);
    copyAppEngineContext(config, copyService);
    copyArtifact(config, copyService);
  }

  @VisibleForTesting
  @Nullable
  static String findEnv(AppYamlProjectStageConfiguration config)
      throws AppEngineException, IOException {
    Path appEngineDirectory = config.getAppEngineDirectory();
    if (appEngineDirectory == null) {
      throw new AppEngineException("Invalid Staging Configuration: missing App Engine directory");
    }
    Path appYaml = appEngineDirectory.resolve(APP_YAML);
    try (InputStream input = Files.newInputStream(appYaml)) {
      return AppYaml.parse(input).getEnvironmentType();
    }
  }

  @VisibleForTesting
  @Nullable
  static String findRuntime(AppYamlProjectStageConfiguration config)
      throws IOException, AppEngineException {
    // verify that app.yaml that contains runtime:java
    Path appEngineDirectory = config.getAppEngineDirectory();
    if (appEngineDirectory == null) {
      throw new AppEngineException("Invalid Staging Configuration: missing App Engine directory");
    }
    Path appYaml = appEngineDirectory.resolve(APP_YAML);
    try (InputStream input = Files.newInputStream(appYaml)) {
      return AppYaml.parse(input).getRuntime();
    }
  }

  @VisibleForTesting
  static void copyDockerContext(
      AppYamlProjectStageConfiguration config, CopyService copyService, @Nullable String runtime)
      throws IOException, AppEngineException {
    Path dockerDirectory = config.getDockerDirectory();
    if (dockerDirectory != null) {
      if (Files.exists(dockerDirectory)) {
        if ("java".equals(runtime)) {
          log.warning(
              "WARNING: runtime 'java' detected, any docker configuration in "
                  + dockerDirectory
                  + " will be ignored. If you wish to specify a docker configuration, please use "
                  + "'runtime: custom'.");
        } else {
          // Copy docker context to staging
          if (!Files.isRegularFile(dockerDirectory.resolve("Dockerfile"))) {
            throw new AppEngineException(
                "Docker directory " + dockerDirectory + " does not contain Dockerfile.");
          } else {
            Path stagingDirectory = config.getStagingDirectory();
            copyService.copyDirectory(dockerDirectory, stagingDirectory);
          }
        }
      }
    }
  }

  @VisibleForTesting
  static void copyAppEngineContext(AppYamlProjectStageConfiguration config, CopyService copyService)
      throws IOException, AppEngineException {
    Path appYaml = config.getAppEngineDirectory().resolve(APP_YAML);
    if (!Files.exists(appYaml)) {
      throw new AppEngineException(APP_YAML + " not found in the App Engine directory.");
    }
    Path stagingDirectory = config.getStagingDirectory();
    copyService.copyFileAndReplace(appYaml, stagingDirectory.resolve(APP_YAML));
  }

  @VisibleForTesting
  static void copyExtraFiles(AppYamlProjectStageConfiguration config, CopyService copyService)
      throws IOException, AppEngineException {
    List<Path> extraFilesDirectories = config.getExtraFilesDirectory();
    if (extraFilesDirectories == null) {
      return;
    }
    for (Path extraFilesDirectory : extraFilesDirectories) {
      if (!Files.exists(extraFilesDirectory)) {
        throw new AppEngineException(
            "Extra files directory does not exist. Location: " + extraFilesDirectory);
      }
      if (!Files.isDirectory(extraFilesDirectory)) {
        throw new AppEngineException(
            "Extra files location is not a directory. Location: " + extraFilesDirectory);
      }
      Path stagingDirectory = config.getStagingDirectory();
      copyService.copyDirectory(extraFilesDirectory, stagingDirectory);
    }
  }

  private static void copyArtifact(AppYamlProjectStageConfiguration config, CopyService copyService)
      throws IOException, AppEngineException {
    Path artifact = config.getArtifact();
    if (Files.exists(artifact)) {
      Path stagingDirectory = config.getStagingDirectory();
      Path destination = stagingDirectory.resolve(artifact.getFileName());
      copyService.copyFileAndReplace(artifact, destination);
    } else {
      throw new AppEngineException("Artifact doesn't exist at '" + artifact + "'.");
    }
  }

  @VisibleForTesting
  // Copies files referenced in "Class-Path" of Jar's MANIFEST.MF to the target directory. Assumes
  // files are present at relative paths and that relative path should be preserved in the staged
  // directory.
  static void copyArtifactJarClasspath(
      AppYamlProjectStageConfiguration config, CopyService copyService) throws IOException {
    Path artifact = config.getArtifact();
    Path targetDirectory = config.getStagingDirectory();
    try (JarFile jarFile = new JarFile(artifact.toFile())) {
      String jarClassPath =
          jarFile.getManifest().getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
      if (jarClassPath == null) {
        return;
      }
      Iterable<String> classpathEntries = Splitter.onPattern("\\s+").split(jarClassPath.trim());
      for (String classpathEntry : classpathEntries) {
        // classpath entries are relative to artifact's position and relativeness should be
        // preserved
        // in the target directory
        Path jarSrc = artifact.getParent().resolve(classpathEntry);
        if (!Files.isRegularFile(jarSrc)) {
          log.warning("Could not copy 'Class-Path' jar: " + jarSrc + " referenced in MANIFEST.MF");
          continue;
        }
        Path jarTarget = targetDirectory.resolve(classpathEntry);

        if (Files.exists(jarTarget)) {
          log.fine(
              "Overwriting 'Class-Path' jar: "
                  + jarTarget
                  + " with "
                  + jarSrc
                  + " referenced in MANIFEST.MF");
        }
        copyService.copyFileAndReplace(jarSrc, jarTarget);
      }
    }
  }

  @VisibleForTesting
  // for non jar artifacts we want to ensure the entrypoint is custom
  static boolean hasCustomEntrypoint(AppYamlProjectStageConfiguration config)
      throws IOException, AppEngineException {
    // verify that app.yaml that contains entrypoint:
    if (config.getAppEngineDirectory() == null) {
      throw new AppEngineException("Invalid Staging Configuration: missing App Engine directory");
    }
    Path appYamlFile = config.getAppEngineDirectory().resolve(APP_YAML);
    try (InputStream input = Files.newInputStream(appYamlFile)) {
      return AppYaml.parse(input).getEntrypoint() != null;
    }
  }

  @VisibleForTesting
  static class CopyService {
    void copyDirectory(Path src, Path dest, List<Path> excludes) throws IOException {
      FileUtil.copyDirectory(src, dest, excludes);
    }

    void copyDirectory(Path src, Path dest) throws IOException {
      FileUtil.copyDirectory(src, dest);
    }

    void copyFileAndReplace(Path src, Path dest) throws IOException {
      if (!Files.exists(dest.getParent())) {
        Files.createDirectories(dest.getParent());
      }
      Files.copy(src, dest, REPLACE_EXISTING);
    }
  }
}