/*
 * 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 org.apache.geode.test.dunit.IgnoredException.addIgnoredException;
import static org.assertj.core.api.Assertions.assertThat;

import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.function.Supplier;

import org.apache.commons.lang.StringUtils;
import org.json.JSONArray;
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.i18n.CliStrings;
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.test.dunit.IgnoredException;

/**
 * Class which eases the connection to the locator/jmxManager in Gfsh shell and execute gfsh
 * commands.
 *
 * <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 GfshShellConnectionRule rule = new GfshShellConnectionRule();
 * </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 GfshShellConnectionRule rule = new GfshShellConnectionRule();
 * </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 NetstatDUnitTest for example.
 */
public class GfshShellConnectionRule extends DescribedExternalResource {

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

  public GfshShellConnectionRule() {
    try {
      temporaryFolder.create();
    } catch (IOException e) {
      throw new UncheckedIOException(e);
    }
  }

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

  @Override
  protected void before(Description description) throws Throwable {
    workingDir = temporaryFolder.newFolder("gfsh_files");
    this.gfsh = new HeadlessGfsh(getClass().getName(), 30, 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();
    }
  }

  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(this.connected).isTrue();
  }

  public void connectAndVerify(int port, PortType type, String... options) throws Exception {
    connect(port, type, options);
    assertThat(this.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(this.connected).isTrue();
  }

  public void connect(int port, PortType type, String... options) throws Exception {
    if (gfsh == null) {
      this.gfsh = new HeadlessGfsh(getClass().getName(), 30,
          temporaryFolder.newFolder("gfsh_files").getAbsolutePath());
    }
    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) {
      endpoint = "http://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.currentThread().sleep(2000);
    }
    connected = (result.getStatus() == Result.Status.OK);
  }

  public void disconnect() throws Exception {
    gfsh.clear();
    executeCommand("disconnect");
    connected = false;
  }

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

  public HeadlessGfsh getHeadlessGfsh() {
    return gfsh;
  }

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

  public CommandResult executeCommand(String command) {
    gfsh.executeCommand(command);
    CommandResult result = null;
    try {
      result = (CommandResult) gfsh.getResult();
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    if (StringUtils.isBlank(gfsh.outputString) && result != null && result.getContent() != null) {
      if (result.getStatus() == Result.Status.ERROR) {
        gfsh.outputString = result.toString();
      } else {
        // print out the message body as the command result
        JSONArray messages = ((JSONArray) result.getContent().get("message"));
        if (messages != null) {
          for (int i = 0; i < messages.length(); i++) {
            gfsh.outputString += messages.getString(i) + "\n";
          }
        }
      }
    }
    System.out.println("Command result for <" + command + ">: \n" + gfsh.outputString);
    return result;
  }

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

  public CommandResult executeAndVerifyCommand(String command) {
    CommandResult result = null;
    try {
      result = executeCommand(command);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
    assertThat(result.getStatus()).describedAs(getGfshOutput()).isEqualTo(Result.Status.OK);
    return result;
  }

  public CommandResult executeAndVerifyCommandError(String command) {
    CommandResult result = null;
    try {
      result = executeCommand(command);
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
    assertThat(result.getStatus()).describedAs(getGfshOutput()).isEqualTo(Result.Status.ERROR);
    return result;
  }

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

  public boolean isConnected() {
    return connected;
  }

  public File getWorkingDir() {
    return workingDir;
  }

  public enum PortType {
    locator, jmxManager, http
  }
}
