/*
 * 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 java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import java.util.Properties;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.logging.log4j.Logger;
import org.codehaus.cargo.container.installer.Installer;
import org.codehaus.cargo.container.installer.ZipURLInstaller;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import org.apache.geode.internal.logging.LogService;
import org.apache.geode.management.internal.configuration.utils.ZipUtils;

/**
 * Base class for handling downloading and configuring J2EE containers.
 *
 * This class contains common logic for downloading and configuring J2EE containers with cargo, and
 * some common methods for applying geode session replication configuration to those containers.
 *
 * Subclasses provide installation of specific containers.
 */
public abstract class ContainerInstall {
  public static final Logger logger = LogService.getLogger();

  private String defaultLocatorAddress;
  private int defaultLocatorPort;

  private final ConnectionType connType;

  private final String INSTALL_PATH;
  private final String MODULE_PATH;
  private final String WAR_FILE_PATH;

  public static final String GEODE_BUILD_HOME = System.getenv("GEODE_HOME");
  public static final String DEFAULT_INSTALL_DIR = "/tmp/cargo_containers/";
  protected static final String DEFAULT_MODULE_LOCATION = GEODE_BUILD_HOME + "/tools/Modules/";
  public static final String DEFAULT_MODULE_EXTRACTION_DIR = "/tmp/cargo_modules/";

  /**
   * Represents the type of connection used in this installation
   *
   * Supports either PEER_TO_PEER or CLIENT_SERVER. Also containers several useful strings needed to
   * identify XML files or connection types when setting up containers.
   */
  public enum ConnectionType {
    PEER_TO_PEER("peer-to-peer", "cache-peer.xml", false, false),
    CLIENT_SERVER("client-server", "cache-client.xml", false, true),
    CACHING_CLIENT_SERVER("client-server", "cache-client.xml", true, true);

    private final String name;
    private final String cacheXMLFileName;
    private final boolean enableLocalCache;
    private final boolean isClientServer;

    ConnectionType(String name, String cacheXMLFileName, boolean enableLocalCache,
        boolean isClientServer) {
      this.name = name;
      this.cacheXMLFileName = cacheXMLFileName;
      this.enableLocalCache = enableLocalCache;
      this.isClientServer = isClientServer;
    }

    public String getName() {
      return name;
    }

    public String getCacheXMLFileName() {
      return cacheXMLFileName;
    }

    public boolean enableLocalCache() {
      return enableLocalCache;
    }

    public boolean isClientServer() {
      return isClientServer;
    }
  }

  public ContainerInstall(String installDir, String downloadURL, ConnectionType connType,
      String moduleName) throws IOException {
    this(installDir, downloadURL, connType, moduleName, DEFAULT_MODULE_LOCATION);
  }

  /**
   * Base class for handling downloading and configuring J2EE installations
   *
   * This class contains common logic for downloading and configuring J2EE installations with cargo,
   * and some common methods for applying geode session replication configuration to those
   * installations.
   *
   * Subclasses provide installation of specific containers.
   * 
   * @param connType Enum representing the connection type of this installation (either client
   *        server or peer to peer)
   * @param moduleName The module name of the installation being setup (i.e. tomcat, appserver,
   *        etc.)
   */
  public ContainerInstall(String installDir, String downloadURL, ConnectionType connType,
      String moduleName, String geodeModuleLocation) throws IOException {
    this.connType = connType;

    clearPreviousInstall(installDir);

    logger.info("Installing container from URL " + downloadURL);

    // Optional step to install the container from a URL pointing to its distribution
    Installer installer = new ZipURLInstaller(new URL(downloadURL), "/tmp/downloads", installDir);
    installer.install();

    // Set install home
    INSTALL_PATH = installer.getHome();
    // Find and extract the module path
    MODULE_PATH = findAndExtractModule(geodeModuleLocation, moduleName);
    logger.info("Extracted module " + moduleName + " to " + MODULE_PATH);
    // Find the session testing war path
    WAR_FILE_PATH = findSessionTestingWar();

    // Default locator
    defaultLocatorPort = 8080;
    defaultLocatorAddress = "localhost";

    logger.info("Installed container into " + getHome());
  }

  public ServerContainer generateContainer(File containerConfigHome) throws IOException {
    return generateContainer(containerConfigHome, "");
  }

  public ServerContainer generateContainer(String containerDescriptors) throws IOException {
    return generateContainer(null, containerDescriptors);
  }

  /**
   * Cleans up the installation by deleting the extracted module and downloaded installation folders
   */
  public void clearPreviousInstall(String installDir) throws IOException {
    File installFolder = new File(installDir);
    // Remove installs from previous runs in the same folder
    if (installFolder.exists()) {
      logger.info("Deleting previous install folder " + installFolder.getAbsolutePath());
      FileUtils.deleteDirectory(installFolder);
    }
  }

  /**
   * Sets the default locator address and port
   */
  public void setDefaultLocator(String address, int port) {
    defaultLocatorAddress = address;
    defaultLocatorPort = port;
  }

  /**
   * Whether the installation is client server
   *
   * Since an installation can only be client server or peer to peer there is no need for a function
   * which checks for a peer to peer installation (just check if not client server).
   */
  public boolean isClientServer() {
    return connType.isClientServer();
  }

  /**
   * Where the installation is located
   */
  public String getHome() {
    return INSTALL_PATH;
  }

  /**
   * Where the module is located
   *
   * The module contains jars needed for geode session setup as well as default templates for some
   * needed XML files.
   */
  public String getModulePath() {
    return MODULE_PATH;
  }

  /**
   * The path to the session testing WAR file
   */
  public String getWarFilePath() {
    return WAR_FILE_PATH;
  }

  /**
   * @return The enum {@link #connType} which represents the type of connection for this
   *         installation
   */
  public ConnectionType getConnectionType() {
    return connType;
  }

  /**
   * Gets the {@link #defaultLocatorAddress}
   *
   * This is the address that a container uses by default. Containers themselves can have their own
   * personal locator address, but will default to this address unless specifically set.
   */
  public String getDefaultLocatorAddress() {
    return defaultLocatorAddress;
  }

  /**
   * Gets the {@link #defaultLocatorPort}
   *
   * This is the port that a container uses by default. Containers themselves can have their own
   * personal locator port, but will default to this port unless specifically set.
   */
  public int getDefaultLocatorPort() {
    return defaultLocatorPort;
  }

  /**
   * Gets the cache XML file to use by default for this installation
   */
  public File getCacheXMLFile() {
    return new File(MODULE_PATH + "/conf/" + getConnectionType().getCacheXMLFileName());
  }

  /**
   * Cargo specific string to identify the container with
   */
  public abstract String getInstallId();

  /**
   * A human readable description of the installation
   */
  public abstract String getInstallDescription();

  /**
   * Get the session manager class to use
   */
  public abstract String getContextSessionManagerClass();

  /**
   * Generates a {@link ServerContainer} from the given {@link ContainerInstall}
   * 
   * @param containerDescriptors Additional descriptors used to identify a container
   */
  public abstract ServerContainer generateContainer(File containerConfigHome,
      String containerDescriptors) throws IOException;

  /**
   * Get the path to the session testing war by walking up directories to the correct folder.
   *
   * NOTE::This walks into the extensions folder and then uses a hardcoded path from there making it
   * very unreliable if things are moved.
   */
  protected static String findSessionTestingWar() {
    // Start out searching directory above current
    String curPath = "../";

    // Looking for extensions folder
    final String warModuleDirName = "extensions";
    File warModuleDir = null;

    // While directory searching for is not found
    while (warModuleDir == null) {
      // Try to find the find the directory in the current directory
      File[] files = new File(curPath).listFiles();
      for (File file : files) {
        if (file.isDirectory() && file.getName().equals(warModuleDirName)) {
          warModuleDir = file;
          break;
        }
      }

      // Keep moving up until you find it
      curPath += "../";
    }

    // Return path to extensions plus hardcoded path from there to the WAR
    return warModuleDir.getAbsolutePath()
        + "/session-testing-war/build/libs/session-testing-war.war";
  }

  /**
   * Finds and extracts the geode module associated with the specified module.
   * 
   * @param moduleName The module name (i.e. tomcat, appserver, etc.) of the module that should be
   *        extract. Used as a search parameter to find the module archive.
   * @return The path to the non-archive (extracted) version of the module files
   */
  protected static String findAndExtractModule(String geodeModuleLocation, String moduleName)
      throws IOException {
    File modulePath = null;
    File modulesDir = new File(geodeModuleLocation);

    boolean archive = false;
    logger.info("Trying to access build dir " + modulesDir);

    // Search directory for tomcat module folder/zip
    for (File file : modulesDir.listFiles()) {

      if (file.getName().toLowerCase().contains(moduleName)) {
        modulePath = file;

        archive = !file.isDirectory();
        if (!archive) {
          break;
        }
      }
    }

    String extractedModulePath =
        modulePath.getName().substring(0, modulePath.getName().length() - 4);
    // Get the name of the new module folder within the extraction directory
    File newModuleFolder = new File(DEFAULT_MODULE_EXTRACTION_DIR + extractedModulePath);
    // Remove any previous module folders extracted here
    if (newModuleFolder.exists()) {
      logger.info("Deleting previous modules directory " + newModuleFolder.getAbsolutePath());
      FileUtils.deleteDirectory(newModuleFolder);
    }

    // Unzip if it is a zip file
    if (archive) {
      if (!FilenameUtils.getExtension(modulePath.getAbsolutePath()).equals("zip")) {
        throw new IOException("Bad module archive " + modulePath);
      }

      // Extract folder to location if not already there
      if (!newModuleFolder.exists()) {
        ZipUtils.unzip(modulePath.getAbsolutePath(), newModuleFolder.getAbsolutePath());
      }

      modulePath = newModuleFolder;
    }

    // No module found within directory throw IOException
    if (modulePath == null) {
      throw new IOException("No module found in " + modulesDir);
    }
    return modulePath.getAbsolutePath();
  }

  /**
   * Edits the specified property within the given property file
   * 
   * @param filePath path to the property file
   * @param propertyName property name to edit
   * @param propertyValue new property value
   * @param append whether or not to append the given property value. If true appends the given
   *        property value the current value. If false, replaces the current property value with the
   *        given property value
   */
  protected static void editPropertyFile(String filePath, String propertyName, String propertyValue,
      boolean append) throws Exception {
    FileInputStream input = new FileInputStream(filePath);
    Properties properties = new Properties();
    properties.load(input);

    String val;
    if (append) {
      val = properties.getProperty(propertyName) + propertyValue;
    } else {
      val = propertyValue;
    }

    properties.setProperty(propertyName, val);
    properties.store(new FileOutputStream(filePath), null);

    logger.info("Modified container Property file " + filePath);
  }

  protected static void editXMLFile(String XMLPath, String tagId, String tagName,
      String parentTagName, HashMap<String, String> attributes) {
    editXMLFile(XMLPath, tagId, tagName, tagName, parentTagName, attributes, false);
  }

  protected static void editXMLFile(String XMLPath, String tagName, String parentTagName,
      HashMap<String, String> attributes) {
    editXMLFile(XMLPath, tagName, parentTagName, attributes, false);
  }

  protected static void editXMLFile(String XMLPath, String tagName, String parentTagName,
      HashMap<String, String> attributes, boolean writeOnSimilarAttributeNames) {
    editXMLFile(XMLPath, null, tagName, tagName, parentTagName, attributes,
        writeOnSimilarAttributeNames);
  }

  protected static void editXMLFile(String XMLPath, String tagName, String replacementTagName,
      String parentTagName, HashMap<String, String> attributes,
      boolean writeOnSimilarAttributeNames) {
    editXMLFile(XMLPath, null, tagName, replacementTagName, parentTagName, attributes,
        writeOnSimilarAttributeNames);
  }

  /**
   * Edit the given xml file
   *
   * Uses {@link #findNodeWithAttribute(Document, String, String, String)},
   * {@link #rewriteNodeAttributes(Node, HashMap)},
   * {@link #nodeHasExactAttributes(Node, HashMap, boolean)} to edit the required parts of the XML
   * file.
   * 
   * @param XMLPath The path to the xml file to edit
   * @param tagId The id of tag to edit. If null, then this method will add a new xml element,
   *        unless writeOnSimilarAttributeNames is set to true.
   * @param tagName The name of the xml element to edit
   * @param replacementTagName The new name of the XML attribute that is being edited
   * @param parentTagName The parent element of the element we should edit
   * @param attributes the xml attributes for the element to edit
   * @param writeOnSimilarAttributeNames If true, find an existing element with the same set of
   *        attributes as the attributes parameter, and modifies the attributes of that element,
   *        rather than adding a new element. If false, create a new XML element (unless tagId is
   *        not null).
   */
  protected static void editXMLFile(String XMLPath, String tagId, String tagName,
      String replacementTagName, String parentTagName, HashMap<String, String> attributes,
      boolean writeOnSimilarAttributeNames) {

    try {
      // Get XML file to edit
      DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
      DocumentBuilder docBuilder = docFactory.newDocumentBuilder();
      Document doc = docBuilder.parse(XMLPath);

      Node node = null;
      // Get node with specified tagId
      if (tagId != null) {
        node = findNodeWithAttribute(doc, tagName, "id", tagId);
      }
      // If writing on similar attributes then search by tag name
      else if (writeOnSimilarAttributeNames) {
        // Get all the nodes with the given tag name
        NodeList nodes = doc.getElementsByTagName(tagName);
        for (int i = 0; i < nodes.getLength(); i++) {
          Node n = nodes.item(i);
          // If the node being iterated across has the exact attributes then it is the one that
          // should be edited
          if (nodeHasExactAttributes(n, attributes, false)) {
            node = n;
            break;
          }
        }
      }
      // If a node if found
      if (node != null) {
        doc.renameNode(node, null, replacementTagName);
        // Rewrite the node attributes
        rewriteNodeAttributes(node, attributes);
        // Write the tagId so that it can be found easier next time
        if (tagId != null)
          ((Element) node).setAttribute("id", tagId);
      }
      // No node found creates new element under the parent tag passed in
      else {
        Element e = doc.createElement(replacementTagName);
        // Set id attribute
        if (tagId != null) {
          e.setAttribute("id", tagId);
        }
        // Set other attributes
        for (String key : attributes.keySet()) {
          e.setAttribute(key, attributes.get(key));
        }

        // Add it as a child of the tag for the file
        doc.getElementsByTagName(parentTagName).item(0).appendChild(e);
      }

      // Write updated XML file
      TransformerFactory transformerFactory = TransformerFactory.newInstance();
      Transformer transformer = transformerFactory.newTransformer();
      DOMSource source = new DOMSource(doc);
      StreamResult result = new StreamResult(new File(XMLPath));
      transformer.transform(source, result);

      logger.info("Modified container XML file " + XMLPath);
    } catch (Exception e) {
      throw new RuntimeException("Unable to edit XML file", e);
    }
  }

  /**
   * Finds the node in the given document with the given name and attribute
   * 
   * @param doc XML document to search for the node
   * @param nodeName The name of the node to search for
   * @param name The name of the attribute that the node should contain
   * @param value The value of the node's given attribute
   * @return Node with the given name, attribute, and attribute value
   */
  private static Node findNodeWithAttribute(Document doc, String nodeName, String name,
      String value) {
    // Get all nodes with given name
    NodeList nodes = doc.getElementsByTagName(nodeName);
    if (nodes == null) {
      return null;
    }

    // Find and return the first node that has the given attribute
    for (int i = 0; i < nodes.getLength(); i++) {
      Node node = nodes.item(i);
      Node nodeAttr = node.getAttributes().getNamedItem(name);

      if (nodeAttr != null && nodeAttr.getTextContent().equals(value)) {
        return node;
      }
    }

    return null;
  }

  /**
   * Replaces the node's attributes with the attributes in the given hashmap
   * 
   * @param node XML node that should be edited
   * @param attributes HashMap of strings representing the attributes of a node (key = value)
   * @return The given node with ONLY the given attributes
   */
  private static Node rewriteNodeAttributes(Node node, HashMap<String, String> attributes) {
    NamedNodeMap nodeAttrs = node.getAttributes();

    // Remove all previous attributes
    while (nodeAttrs.getLength() > 0) {
      nodeAttrs.removeNamedItem(nodeAttrs.item(0).getNodeName());
    }

    // Set to new attributes
    for (String key : attributes.keySet()) {
      ((Element) node).setAttribute(key, attributes.get(key));
    }

    return node;
  }

  /**
   * Checks to see whether the given XML node has the exact attributes given in the attributes
   * hashmap
   * 
   * @param checkSimilarValues If true, will also check to make sure that the given node's
   *        attributes also have the exact same values as the ones given in the attributes HashMap.
   * @return True if the node has only the attributes the are given by the HashMap (no more and no
   *         less attributes). If {@param checkSimilarValues} is true then only returns true if the
   *         node shares attributes with the given attribute list exactly.
   */
  private static boolean nodeHasExactAttributes(Node node, HashMap<String, String> attributes,
      boolean checkSimilarValues) {
    NamedNodeMap nodeAttrs = node.getAttributes();

    // Check to make sure the node has all attribute fields
    for (String key : attributes.keySet()) {
      Node attr = nodeAttrs.getNamedItem(key);
      if (attr == null
          || (checkSimilarValues && !attr.getTextContent().equals(attributes.get(key)))) {
        return false;
      }
    }

    // Check to make sure the node does not have more than the attribute fields
    for (int i = 0; i < nodeAttrs.getLength(); i++) {
      String attr = nodeAttrs.item(i).getNodeName();
      if (attributes.get(attr) == null || (checkSimilarValues
          && !attributes.get(attr).equals(nodeAttrs.item(i).getTextContent()))) {
        return false;
      }
    }

    return true;
  }
}
