/*
 * 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.test.junit.rules;

import static java.util.stream.Collectors.joining;
import static org.apache.geode.test.dunit.IgnoredException.addIgnoredException;
import static org.apache.geode.test.process.JavaModuleHelper.getJvmModuleOptions;
import static org.assertj.core.api.Assertions.assertThat;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Path;
import java.util.Properties;
import java.util.function.Supplier;

import org.apache.commons.lang3.StringUtils;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.Description;

import org.apache.geode.management.cli.Result;
import org.apache.geode.management.internal.cli.HeadlessGfsh;
import org.apache.geode.management.internal.cli.LogWrapper;
import org.apache.geode.management.internal.cli.result.CommandResult;
import org.apache.geode.management.internal.cli.shell.Gfsh;
import org.apache.geode.management.internal.cli.util.CommandStringBuilder;
import org.apache.geode.management.internal.i18n.CliStrings;
import org.apache.geode.test.dunit.IgnoredException;
import org.apache.geode.test.junit.assertions.CommandResultAssert;

/**
 * Class which eases the connection to the locator/jmxManager in Gfsh shell and execute gfsh
 * commands. This rule can't be used to start locators or servers; instead use GfshRule.
 *
 * <p>
 * if used with {@link ConnectionConfiguration}, you will need to specify a port number when
 * constructing this rule. Then this rule will do auto connect for you before running your test.
 *
 * <p>
 * otherwise, you can call connect with the specific port number yourself in your test. This rules
 * handles closing your connection and gfsh instance.
 *
 * <p>
 * you can use this as Rule:
 *
 * <pre>
 * {@literal @}Rule GfshCommandRule rule = new GfshCommandRule();
 * </pre>
 *
 * then after you connect to a locator, you don't have to call disconnect() or close() at all, since
 * the rule's after takes care of it for you.
 *
 * <p>
 * Or as a ClassRule:
 *
 * <pre>
 * {@literal @}ClassRule GfshCommandRule rule = new GfshCommandRule();
 * </pre>
 *
 * When using as a ClassRule, if you call connect in a test, you will need to call disconnect after
 * the test as well. See org.apache.geode.management.internal.cli.NetstatDUnitTest for
 * example.
 */
public class GfshCommandRule extends DescribedExternalResource {

  private final Supplier<Integer> portSupplier;
  private PortType portType = PortType.jmxManager;
  private HeadlessGfsh gfsh = null;
  private int gfshTimeout = 2;
  private boolean connected = false;
  private IgnoredException ignoredException;
  private final TemporaryFolder temporaryFolder = new TemporaryFolder();
  private File workingDir;
  private CommandResult commandResult;

  public GfshCommandRule() {
    this(null, null);
  }

  public GfshCommandRule(Supplier<Integer> portSupplier, PortType portType) {
    createTempFolder();
    this.portType = portType;
    this.portSupplier = portSupplier;
  }

  @Override
  protected void before(Description description) throws Throwable {
    LogWrapper.close();
    createTempFolder();
    try {
      workingDir = temporaryFolder.newFolder("gfsh_files");
    } catch (IOException e) {
      workingDir = temporaryFolder.getRoot();
    }
    gfsh = new HeadlessGfsh(getClass().getName(), gfshTimeout, workingDir.getAbsolutePath());
    ignoredException =
        addIgnoredException("java.rmi.NoSuchObjectException: no such object in table");

    // do not auto connect if no port initialized
    if (portSupplier == null) {
      return;
    }

    ConnectionConfiguration config = description.getAnnotation(ConnectionConfiguration.class);
    if (config == null) {
      connectAndVerify(portSupplier.get(), portType);
    } else {
      // when config is not null, developer may deliberately pass in a wrong
      // password so that the test will verify the connection itself. So do not verify here.
      secureConnect(portSupplier.get(), portType, config.user(), config.password());
    }
  }

  @Override
  protected void after(Description description) throws Throwable {
    close();

    if (ignoredException != null) {
      ignoredException.remove();
    }
  }

  private void createTempFolder() {
    // check if the temp folder exists and create it if needed
    // an IllegalStateException will be thrown if the temp folder does not exist
    try {
      temporaryFolder.getRoot();
    } catch (IllegalStateException e) {
      try {
        temporaryFolder.create();
      } catch (IOException ioe) {
        throw new UncheckedIOException(ioe);
      }
    }
  }

  public void connect(Member locator, String... options) throws Exception {
    connect(locator.getPort(), PortType.locator, options);
  }

  public void connectAndVerify(Member locator, String... options) throws Exception {
    connect(locator.getPort(), PortType.locator, options);
    assertThat(connected).isTrue();
  }

  public void connectAndVerify(int port, PortType type, Properties properties) throws Exception {
    connect(port, type, properties);
    assertThat(connected).isTrue();
  }

  public void connectAndVerify(int port, PortType type, String... options) throws Exception {
    connect(port, type, options);
    assertThat(connected).isTrue();
  }

  public void secureConnect(int port, PortType type, String username, String password)
      throws Exception {
    connect(port, type, CliStrings.CONNECT__USERNAME, username, CliStrings.CONNECT__PASSWORD,
        password);
  }

  public void secureConnectAndVerify(int port, PortType type, String username, String password)
      throws Exception {
    connect(port, type, CliStrings.CONNECT__USERNAME, username, CliStrings.CONNECT__PASSWORD,
        password);
    assertThat(connected).isTrue();
  }

  public void secureConnectWithTokenAndVerify(int port, PortType portType, String token)
      throws Exception {
    connect(port, portType, CliStrings.CONNECT__TOKEN, token);
    assertThat(connected).isTrue();
  }

  public void connect(int port, PortType type, Properties properties) throws Exception {
    File propertyFile = temporaryFolder.newFile();
    FileOutputStream out = new FileOutputStream(propertyFile);
    properties.store(out, null);
    connect(port, type, "security-properties-file", propertyFile.getAbsolutePath());
  }

  public void connect(int port, PortType type, String... options) throws Exception {
    if (gfsh == null) {
      String absolutePath;
      try {
        absolutePath = temporaryFolder.newFolder("gfsh_files").getAbsolutePath();
      } catch (IOException e) {
        absolutePath = temporaryFolder.getRoot().getAbsolutePath();
      }

      gfsh = new HeadlessGfsh(getClass().getName(), 30, absolutePath);
    }
    final CommandStringBuilder connectCommand = new CommandStringBuilder(CliStrings.CONNECT);
    String endpoint;
    if (type == PortType.locator) {
      // port is the locator port
      endpoint = "localhost[" + port + "]";
      connectCommand.addOption(CliStrings.CONNECT__LOCATOR, endpoint);
    } else if (type == PortType.http || type == PortType.https) {
      endpoint = type.name() + "://localhost:" + port + "/geode-mgmt/v1";
      // connectCommand.addOption(CliStrings.CONNECT__USE_HTTP, Boolean.TRUE.toString());
      connectCommand.addOption(CliStrings.CONNECT__URL, endpoint);
    } else {
      endpoint = "localhost[" + port + "]";
      connectCommand.addOption(CliStrings.CONNECT__JMX_MANAGER, endpoint);
    }

    // add the extra options
    if (options != null) {
      for (int i = 0; i < options.length; i += 2) {
        connectCommand.addOption(options[i], options[i + 1]);
      }
    }

    // when we connect too soon, we would get "Failed to retrieve RMIServer stub:
    // javax.naming.CommunicationException [Root exception is java.rmi.NoSuchObjectException: no
    // such object in table]" Exception.
    // can not use Awaitility here because it starts another thread, but the Gfsh instance is in a
    // threadLocal variable, See Gfsh.getExistingInstance()
    CommandResult result = null;
    for (int i = 0; i < 50; i++) {
      result = executeCommand(connectCommand.toString());
      if (!gfsh.outputString.contains("no such object in table")) {
        break;
      }
      Thread.sleep(2000);
    }
    connected = (result.getStatus() == Result.Status.OK);
  }

  public void disconnect() throws Exception {
    gfsh.clear();

    // Stop the operation invoker FIRST to set intentional disconnect flags
    // This must be done even if isConnectedAndReady() returns false
    Gfsh gfshInstance = gfsh.getGfsh();
    if (gfshInstance != null && gfshInstance.getOperationInvoker() != null) {
      gfshInstance.getOperationInvoker().stop();
    }

    // Then execute disconnect command (may return early if not ready)
    executeCommand("disconnect");
    connected = false;
  }

  public void close() throws Exception {
    temporaryFolder.delete();
    if (connected) {
      disconnect();
    }
    gfsh.executeCommand("exit");
    gfsh.terminate();
    LogWrapper.close();
    gfsh = null;
  }

  public Gfsh getGfsh() {
    return gfsh.getGfsh();
  }

  /**
   * @param command the String command to execute
   * @return Command result object
   *
   * @deprecated use executeAndAssertThat(command). if you really need to get the result object
   *             to do assertion, you can use hasTable().getActual() or hasData().getActual
   *             after that method call.
   */
  @Deprecated
  public CommandResult executeCommand(String command) {
    String moduleArgs = "";
    if (command.matches("^start +server.*") || command.matches("^start +locator.*")) {
      moduleArgs = getJvmModuleOptions().stream()
          .map(s -> "--J=" + s)
          .collect(joining(" ", " ", ""));
    }

    gfsh.executeCommand(command + moduleArgs);

    CommandResult result;
    try {
      result = gfsh.getResult();
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    if (StringUtils.isBlank(gfsh.outputString) && result != null) {
      gfsh.outputString = result.asString();
    }
    System.out.println("Command result for <" + command + ">: \n" + gfsh.outputString);
    commandResult = result;
    return result;
  }

  public CommandResultAssert executeAndAssertThat(String command) {
    commandResult = executeCommand(command);
    return new CommandResultAssert(commandResult);
  }

  public String getGfshOutput() {
    return gfsh.outputString;
  }


  public String execute(String command) throws Exception {
    executeCommand(command);
    return gfsh.outputString;
  }

  public boolean isConnected() {
    return connected;
  }

  public File getWorkingDir() {
    return workingDir;
  }

  /**
   * Returns the path to the gfsh log file where gfsh commands are logged.
   * <p>
   * This is useful for tests that need to verify command logging and password redaction.
   * The log file is configured via the log4j2-cli.xml configuration using the
   * gfsh.log.file system property.
   *
   * @return the absolute path to the gfsh log file
   * @throws IllegalStateException if gfsh has not been initialized
   */
  public Path getGfshLogFile() {
    if (gfsh == null) {
      throw new IllegalStateException("Gfsh has not been initialized");
    }
    return gfsh.getGfshLogFile();
  }

  public GfshCommandRule withTimeout(int timeoutInSeconds) {
    gfshTimeout = timeoutInSeconds;
    return this;
  }

  public enum PortType {
    locator, jmxManager, http, https
  }

  public CommandResult getCommandResult() {
    return commandResult;
  }
}
