/*
 * 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.geode.session.tests;

import static java.nio.file.Files.copy;
import static java.util.stream.Collectors.joining;
import static org.apache.geode.test.process.JavaModuleHelper.getJvmModuleOptions;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.HashMap;
import java.util.UUID;
import java.util.function.IntSupplier;
import java.util.stream.Stream;

import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.Logger;
import org.codehaus.cargo.container.ContainerType;
import org.codehaus.cargo.container.InstalledLocalContainer;
import org.codehaus.cargo.container.State;
import org.codehaus.cargo.container.configuration.Configuration;
import org.codehaus.cargo.container.configuration.ConfigurationType;
import org.codehaus.cargo.container.configuration.LocalConfiguration;
import org.codehaus.cargo.container.deployable.WAR;
import org.codehaus.cargo.container.property.GeneralPropertySet;
import org.codehaus.cargo.container.property.LoggingLevel;
import org.codehaus.cargo.container.property.ServletPropertySet;
import org.codehaus.cargo.container.tomcat.TomcatPropertySet;
import org.codehaus.cargo.generic.DefaultContainerFactory;
import org.codehaus.cargo.generic.configuration.DefaultConfigurationFactory;

import org.apache.geode.logging.internal.log4j.api.LogService;

/**
 * Base class for handling the setup and configuration of cargo containers
 *
 * This class contains common logic for setting up and configuring cargo containers for J2EE
 * container installations. Also includes some common methods for applying geode session replication
 * configuration to those containers.
 *
 * Subclasses provide setup and configuration of specific containers.
 */
public abstract class ServerContainer {

  protected static final Logger logger = LogService.getLogger();

  private static final String DEFAULT_LOGGING_LEVEL = LoggingLevel.LOW.getLevel();

  private final Path containerConfigHome;
  private final IntSupplier portSupplier;
  private final InstalledLocalContainer container;
  private final ContainerInstall install;

  private String locatorAddress;
  private int locatorPort;
  private Path warFile;

  public String description;
  public Path cacheXMLFile;
  public Path cargoLogDir;

  private String loggingLevel;

  protected HashMap<String, String> cacheProperties;
  protected HashMap<String, String> systemProperties;

  final Path defaultConfigDir;

  /**
   * Sets up the container using the given installation
   *
   * Sets up a bunch of logging files, default locations, and container properties.
   *
   * Creates a whole new cargo configuration and cargo container for the {@link #container}
   * variable.
   *
   * @param install the installation with which to set up the container
   * @param rootDir The root folder used by default for cargo logs, container configs and other
   *        files and directories
   * @param containerConfigHome The folder that the container configuration folder should be setup
   *        in
   * @param containerDescriptors A string of extra descriptors for the container used in the
   *        containers {@link #description}
   * @param portSupplier allocates ports for use by the container
   * @throws IOException if an exception is encountered
   */
  public ServerContainer(ContainerInstall install, Path rootDir, Path containerConfigHome,
      String containerDescriptors, IntSupplier portSupplier) throws IOException {
    this.install = install;
    this.portSupplier = portSupplier;
    // Get a container description for logging and output
    description = generateUniqueContainerDescription(containerDescriptors);
    // Setup logging
    loggingLevel = DEFAULT_LOGGING_LEVEL;
    cargoLogDir = rootDir.resolve("cargo_logs").resolve(description);
    Files.createDirectories(cargoLogDir);

    logger.info("Creating new container {}", description);

    defaultConfigDir = install.getHome().resolve("conf");

    // Use the default configuration home path if not passed a config home
    Path defaultConfigDir = rootDir.resolve("cargo_configs");

    this.containerConfigHome = containerConfigHome == null
        ? defaultConfigDir.resolve(description) : containerConfigHome;

    // Init the property lists
    cacheProperties = new HashMap<>();
    systemProperties = new HashMap<>();
    // Set WAR file to session testing war
    warFile = install.getWarFilePath();

    // Create the Cargo Container instance wrapping our physical container
    Configuration configuration = new DefaultConfigurationFactory()
        .createConfiguration(install.getInstallId(), ContainerType.INSTALLED,
            ConfigurationType.STANDALONE, this.containerConfigHome.toString());

    // Set configuration/container logging level
    configuration.setProperty(GeneralPropertySet.LOGGING, loggingLevel);
    // Removes secureRandom generation so that container startup is much faster
    configuration.setProperty(GeneralPropertySet.JVMARGS,
        "-Djava.security.egd=file:/dev/./urandom -Xmx256m -Xms64m");

    // Setup the gemfire log file for this container
    Path gemfireLogFile = cargoLogDir.resolve("gemfire.log");
    Files.createDirectories(cargoLogDir);
    setSystemProperty("log-file", gemfireLogFile.toString());

    logger.info("Gemfire logs can be found in {}", gemfireLogFile);

    // Create the container
    container = (InstalledLocalContainer) new DefaultContainerFactory()
        .createContainer(install.getInstallId(), ContainerType.INSTALLED, configuration);
    // Set container's home dir to where it was installed
    container.setHome(install.getHome().toString());
    // Set container output log to directory setup for it
    container.setOutput(cargoLogDir.resolve("container.log").toString());

    // Set cacheXML file
    Path installXMLFile = install.getCacheXMLFile();
    // Sets the cacheXMLFile variable and adds the cache XML file server system property map
    setCacheXMLFile(cargoLogDir.resolve(installXMLFile.getFileName()));
    // Copy the cacheXML file to a new, unique location for this container
    copy(installXMLFile, cacheXMLFile);
  }

  /*
   * Generates a unique, mostly human readable, description string of the container using the
   * installation's description, extraIdentifiers, and the current system nano time
   */
  private String generateUniqueContainerDescription(String extraIdentifiers) {
    return String.join("_", Arrays.asList(install.getInstallDescription(), extraIdentifiers,
        UUID.randomUUID().toString()));
  }

  /**
   * Deploys the {@link #warFile} to the cargo container ({@link #container}).
   */
  protected void deployWar() {
    // Get the cargo war from the war file
    WAR war = new WAR(warFile.toString());
    // Set context access to nothing
    war.setContext("");
    // Deploy the war the container's configuration
    getConfiguration().addDeployable(war);

    logger.info("Deployed WAR file at {}", war.getFile());
  }

  /**
   * Starts this cargo container by picking the container's ports (RMI, AJP, and regular) and
   * calling the cargo container's start function
   */
  public void start() {
    if (container.getState().isStarted()) {
      throw new IllegalArgumentException("Container " + description
          + " failed to start because it is currently " + container.getState());
    }

    LocalConfiguration config = getConfiguration();
    // Set container ports from available ports
    int servletPort = portSupplier.getAsInt();
    int containerRmiPort = portSupplier.getAsInt();
    int tomcatAjpPort = portSupplier.getAsInt();
    config.setProperty(ServletPropertySet.PORT, Integer.toString(servletPort));
    config.setProperty(GeneralPropertySet.RMI_PORT, Integer.toString(containerRmiPort));
    config.setProperty(TomcatPropertySet.AJP_PORT, Integer.toString(tomcatAjpPort));
    config.setProperty(GeneralPropertySet.PORT_OFFSET, "0");
    int jvmJmxPort = portSupplier.getAsInt();
    String jvmArgs = "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=" + jvmJmxPort;
    jvmArgs += getJvmModuleOptions().stream()
        .collect(joining(" ", " ", ""));
    config.setProperty(GeneralPropertySet.START_JVMARGS, jvmArgs);
    container.setConfiguration(config);


    try {
      logger.info("Starting container {} RMI Port: {}", description, jvmJmxPort);
      // Writes settings to the expected form (either XML or WAR file)
      writeSettings();
      // Start the container through cargo
      container.start();
    } catch (Exception e) {
      throw new RuntimeException(
          "Something very bad happened to this container when starting. Check the cargo_logs folder for container logs.",
          e);
    }
  }

  /**
   * Stops this cargo container
   */
  public void stop() {
    if (!container.getState().isStarted()) {
      throw new IllegalArgumentException("Container " + description
          + " failed to stop because it is currently " + container.getState());
    }

    container.stop();
  }

  public void dumpLogs() {
    System.out.println("Logs for container " + this);
    dumpLogsInDir(cargoLogDir);
    dumpLogsInDir(containerConfigHome.resolve("logs"));
    dumpConfiguration();
  }

  private static void dumpLogsInDir(Path dir) {
    try (Stream<Path> paths = Files.list(dir)) {
      paths.forEach(ServerContainer::dumpToStdOut);
    } catch (IOException thrown) {
      System.out.println("-------------------------------------------");
      System.out.println("Exception while dumping log files from directory to stdout.");
      System.out.println("   Directory: " + dir.toAbsolutePath());
      System.out.println("   Exception: " + thrown);
      System.out.println("-------------------------------------------");
    }
  }

  private static void dumpToStdOut(Path path) {
    System.out.println("-------------------------------------------");
    System.out.println(path.toAbsolutePath());
    System.out.println("-------------------------------------------");
    try {
      copy(path, System.out);
    } catch (IOException thrown) {
      System.out.println("Exception while dumping log file to stdout.");
      System.out.println("   File: " + path.toAbsolutePath());
      System.out.println("   Exception: " + thrown);
    }
    System.out.println("-------------------------------------------");
    System.out.println();
  }

  private void dumpConfiguration() {
    System.out.println("-------------------------------------------");
    System.out.println("Configuration for container " + this);
    System.out.println("-------------------------------------------");
    LocalConfiguration configuration = getConfiguration();
    System.out.format("Name: %s%n", configuration);
    System.out.format("Class: %s%n", configuration.getClass());
    System.out.println("Properties:");
    configuration.getProperties().entrySet().forEach(System.out::println);
    System.out.println("-------------------------------------------");
    System.out.println();
  }

  /**
   * Copies the container configuration (found through {@link #getConfiguration()}) to the logging
   * directory specified by {@link #cargoLogDir}
   *
   * @throws IOException if an exception is encountered when deleting files
   */
  public void cleanUp() throws IOException {
    File configDir = new File(getConfiguration().getHome());

    if (configDir.exists()) {
      logger.info("Deleting configuration folder {}", configDir.getAbsolutePath());
      FileUtils.deleteDirectory(configDir);
    }
  }

  /**
   * Sets the container's locator
   *
   * Sets the two variables {@link #locatorAddress} and {@link #locatorPort}. Also calls the
   * {@link #updateLocator()} function to write the updated locator properties to the file.
   *
   * @param address the host name or ip address that the locator is listening on
   * @param port the port that the locator is listening on
   * @throws IOException if an exception is encountered when updating the locator property file
   */
  protected void setLocator(String address, int port) throws IOException {
    locatorAddress = address;
    locatorPort = port;
    updateLocator();
  }

  /*
   * Sets the container's cache XML file
   */
  private void setCacheXMLFile(Path cacheXMLFile) throws IOException {
    setSystemProperty("cache-xml-file", cacheXMLFile.toString());
    this.cacheXMLFile = cacheXMLFile;
  }

  /*
   * Set a geode session replication property
   */
  protected String setCacheProperty(String name, String value) throws IOException {
    return cacheProperties.put(name, value);
  }

  /*
   * Set geode distributed system property
   */
  private String setSystemProperty(String name, String value) throws IOException {
    return systemProperties.put(name, value);
  }

  /*
   * Sets the war file for this container to deploy and use
   */
  void setWarFile(Path warFile) {
    this.warFile = warFile;
  }

  /*
   * set the container's logging level
   */
  public void setLoggingLevel(String loggingLevel) {
    this.loggingLevel = loggingLevel;

    LocalConfiguration config = getConfiguration();
    config.setProperty(GeneralPropertySet.LOGGING, loggingLevel);
    container.setConfiguration(config);
  }

  public InstalledLocalContainer getContainer() {
    return container;
  }

  public ContainerInstall getInstall() {
    return install;
  }

  public Path getWarFile() {
    return warFile;
  }

  public String getLoggingLevel() {
    return loggingLevel;
  }

  public LocalConfiguration getConfiguration() {
    return container.getConfiguration();
  }

  public State getState() {
    return container.getState();
  }

  protected String getCacheProperty(String name) {
    return cacheProperties.get(name);
  }

  protected String getSystemProperty(String name) {
    return systemProperties.get(name);
  }

  /**
   * Get the RMI port for the container
   *
   * Calls {@link #getPort(String)} with the {@link GeneralPropertySet#RMI_PORT} option.
   *
   * @return the RMI port for the container
   */
  public String getRMIPort() {
    return getPort(GeneralPropertySet.RMI_PORT);
  }

  /**
   * Get the basic port for the container
   *
   * Calls {@link #getPort(String)} with the {@link ServletPropertySet#PORT} option.
   *
   * @return the basic port for the container
   */
  public String getPort() {
    return getPort(ServletPropertySet.PORT);
  }

  /*
   * The container's port for the specified port type
   */
  public String getPort(String portType) {
    LocalConfiguration config = getConfiguration();
    config.applyPortOffset();

    if (!container.getState().isStarted()) {
      throw new IllegalStateException(
          "Container is not started, thus a port has not yet been assigned to the container.");
    }

    return config.getPropertyValue(portType);
  }

  /*
   * Called before each container startup
   *
   * This is mainly used to write properties to whatever format they need to be in for a given
   * container before the container is started. The reason for doing this is to make sure that
   * expensive property updates (such as writing to an XML file or building WAR files from the
   * command line) only happen as often as they are needed. These kinds of updates usually only need
   * to happen on container startup.
   */
  public abstract void writeSettings() throws Exception;

  /**
   * Human readable description of the container
   *
   * @return The {@link #description} variable along with the state of this {@link #container}
   */
  @Override
  public String toString() {
    return description + "_<" + container.getState() + ">";
  }

  /**
   * Updates the address and port of the locator for this container
   *
   * For Client Server installations the {@link #cacheXMLFile} is updated with the new address and
   * port. For Peer to Peer installations the locator must be specified as a system property and so
   * is added to the {@link #systemProperties} map under the 'locators' key in the form of
   * '{@link #locatorAddress}[{@link #locatorPort}]'.
   */
  private void updateLocator() throws IOException {
    if (getInstall().isClientServer()) {
      HashMap<String, String> attributes = new HashMap<>();
      attributes.put("host", locatorAddress);
      attributes.put("port", Integer.toString(locatorPort));

      ContainerInstall.editXMLFile(cacheXMLFile, "locator", "pool", attributes, true);
    } else {
      setSystemProperty("locators", locatorAddress + "[" + locatorPort + "]");
    }
  }
}
