/*
 * The Alluxio Open Foundation licenses this work under the Apache License, version 2.0
 * (the "License"). You may not use this work except in compliance with the License, which is
 * available at www.apache.org/licenses/LICENSE-2.0
 *
 * This software is distributed on an "AS IS" basis, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
 * either express or implied, as more fully set forth in the License.
 *
 * See the NOTICE file distributed with this work for information regarding copyright ownership.
 */

package alluxio.util;

import alluxio.shell.CommandReturn;
import alluxio.shell.ScpCommand;
import alluxio.shell.ShellCommand;
import alluxio.shell.SshCommand;

import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.concurrent.ThreadSafe;

/**
 * A utility class for running Unix commands.
 */
@ThreadSafe
public final class ShellUtils {
  private static final Logger LOG = LoggerFactory.getLogger(ShellUtils.class);

  /**
   * Common shell OPTS to prevent stalling.
   * a. Disable StrictHostKeyChecking to prevent interactive prompt
   * b. Set timeout for establishing connection with host
   */
  public static final String COMMON_SSH_OPTS = "-o StrictHostKeyChecking=no -o ConnectTimeout=5";

  /** a Unix command to set permission. */
  public static final String SET_PERMISSION_COMMAND = "chmod";

  /** a Unix command for getting mount information. */
  public static final String MOUNT_COMMAND = "mount";

  /**
   * Gets a Unix command to get a given user's effective groups list.
   *
   * @param user the user name
   * @return the Unix command to get a given user's effective groups list
   */
  public static String[] getEffectiveGroupsForUserCommand(final String user) {
    return new String[] {"id", "-gn", user};
  }

  /**
   * Gets a Unix command to get a given user's all groups list.
   *
   * @param user the user name
   * @return the Unix command to get a given user's all groups list
   */
  public static String[] getAllGroupsForUserCommand(final String user) {
    return new String[] {"id", "-Gn", user};
  }

  /**
   * Returns a Unix command to set permission.
   *
   * @param perm the permission of file
   * @param filePath the file path
   * @return the Unix command to set permission
   */
  public static String[] getSetPermissionCommand(String perm, String filePath) {
    return new String[] {SET_PERMISSION_COMMAND, perm, filePath};
  }

  /** Token separator regex used to parse Shell tool outputs. */
  public static final String TOKEN_SEPARATOR_REGEX = "[ \t\n\r\f]";

  /**
   * Gets system mount information. This method should only be attempted on Unix systems.
   *
   * @return system mount information
   */
  public static List<UnixMountInfo> getUnixMountInfo() throws IOException {
    Preconditions.checkState(OSUtils.isLinux() || OSUtils.isMacOS());
    String output = execCommand(MOUNT_COMMAND);
    List<UnixMountInfo> mountInfo = new ArrayList<>();
    for (String line : output.split("\n")) {
      mountInfo.add(parseMountInfo(line));
    }
    return mountInfo;
  }

  /**
   * @param line the line to parse
   * @return the parsed {@link UnixMountInfo}
   */
  public static UnixMountInfo parseMountInfo(String line) {
    // Example mount lines:
    // ramfs on /mnt/ramdisk type ramfs (rw,relatime,size=1gb)
    // map -hosts on /net (autofs, nosuid, automounted, nobrowse)
    UnixMountInfo.Builder builder = new UnixMountInfo.Builder();

    // First get and remove the mount type if it's provided.
    Matcher matcher = Pattern.compile(".* (type \\w+ ).*").matcher(line);
    String lineWithoutType;
    if (matcher.matches()) {
      String match = matcher.group(1);
      builder.setFsType(match.replace("type", "").trim());
      lineWithoutType = line.replace(match, "");
    } else {
      lineWithoutType = line;
    }
    // Now parse the rest
    matcher = Pattern.compile("(.*) on (.*) \\((.*)\\)").matcher(lineWithoutType);
    if (!matcher.matches()) {
      LOG.warn("Unable to parse output of '{}': {}", MOUNT_COMMAND, line);
      return builder.build();
    }
    builder.setDeviceSpec(matcher.group(1));
    builder.setMountPoint(matcher.group(2));
    builder.setOptions(parseUnixMountOptions(matcher.group(3)));
    return builder.build();
  }

  private static UnixMountInfo.Options parseUnixMountOptions(String line) {
    UnixMountInfo.Options.Builder builder = new UnixMountInfo.Options.Builder();
    for (String option : line.split(",")) {
      Matcher matcher = Pattern.compile("(.*)=(.*)").matcher(option.trim());
      if (matcher.matches() && matcher.group(1).equalsIgnoreCase("size")) {
        try {
          builder.setSize(FormatUtils.parseSpaceSize(matcher.group(2)));
        } catch (IllegalArgumentException e) {
          LOG.debug("Failed to parse mount point size", e);
        }
      }
    }
    return builder.build();
  }

  /**
   * Checks whether a path is the mounting point of a RAM disk volume.
   *
   * @param path  a string represents the path to be checked
   * @param fsTypes an array of strings represents expected file system type
   * @return true if the path is the mounting point of volume with one of the given fsTypes,
   *         false otherwise
   * @throws IOException if the function fails to get the mount information of the system
   */
  public static boolean isMountingPoint(String path, String[] fsTypes) throws IOException {
    List<UnixMountInfo> infoList = getUnixMountInfo();
    for (UnixMountInfo info : infoList) {
      Optional<String> mountPoint = info.getMountPoint();
      Optional<String> fsType = info.getFsType();
      if (mountPoint.isPresent() && mountPoint.get().equals(path) && fsType.isPresent()) {
        for (String expectedType : fsTypes) {
          if (fsType.get().equalsIgnoreCase(expectedType)) {
            return true;
          }
        }
      }
    }
    return false;
  }

  /**
   * Checks whether an Alluxio service is running.
   *
   * @param className class name of the Alluxio service
   * @return whether the Alluxio service is running
   */
  public static boolean isAlluxioRunning(String className) {
    String[] command = {"bash", "-c",
        "ps -Aww -o command | grep -i \"[j]ava\" | grep " + className};
    try {
      Process p = Runtime.getRuntime().exec(command);
      try (InputStreamReader input = new InputStreamReader(p.getInputStream())) {
        if (input.read() >= 0) {
          return true;
        }
      }
      return false;
    } catch (IOException e) {
      System.err.format("Unable to check Alluxio status: %s.%n", e.getMessage());
      return false;
    }
  }

  /**
   * This is an IOException with exit code added.
   */
  public static class ExitCodeException extends IOException {

    private static final long serialVersionUID = -6520494427049734809L;

    private final int mExitCode;

    /**
     * Constructs an ExitCodeException.
     *
     * @param exitCode the exit code returns by shell
     * @param message the exception message
     */
    public ExitCodeException(int exitCode, String message) {
      super(message);
      mExitCode = exitCode;
    }

    /**
     * Gets the exit code.
     *
     * @return the exit code
     */
    public int getExitCode() {
      return mExitCode;
    }

    @Override
    public String toString() {
      final StringBuilder sb = new StringBuilder("ExitCodeException ");
      sb.append("exitCode=").append(mExitCode).append(": ");
      sb.append(super.getMessage());
      return sb.toString();
    }
  }

  /**
   * Static method to execute a shell command. The StdErr is not returned.
   * If execution failed
   *
   * @param cmd shell command to execute
   * @return the output of the executed command
   * @throws IOException in various situations:
   *  1. {@link ExitCodeException} when exit code is non-zero
   *  2. when the executable is not valid, i.e. running ls in Windows
   *  3. execution interrupted
   *  4. other normal reasons for IOException
   */
  public static String execCommand(String... cmd) throws IOException {
    return new ShellCommand(cmd).run();
  }

  /**
   * Static method to execute a shell command and tolerate non-zero exit code.
   * Preserves exit code. Stderr is redirected to stdout.
   *
   * @param cmd shell command to execute
   * @return the output of the executed command
   * @throws IOException in various situations:
   *  1. when the executable is not valid, i.e. running ls in Windows
   *  2. execution interrupted
   *  3. other normal reasons for IOException
   */
  public static CommandReturn execCommandWithOutput(String... cmd) throws IOException {
    return new ShellCommand(cmd).runWithOutput();
  }

  /**
   * Static method to execute a shell command remotely via ssh.
   * Preserves exit code. Stderr redirected to stdout.
   * SSH must be password-less.
   *
   * @param hostname Hostname where the command should execute
   * @param cmd shell command to execute
   * @return the output of the executed command
   * @throws IOException in various situations:
   *  1. when the executable is not valid, i.e. running ls in Windows
   *  2. execution interrupted
   *  3. other normal reasons for IOException
   */
  public static CommandReturn sshExecCommandWithOutput(String hostname, String... cmd)
          throws IOException {
    return new SshCommand(hostname, cmd).runWithOutput();
  }

  /**
   * Static method to execute an scp command to copy a remote file/dir to local.
   * Preserves exit code. Stderr redirected to stdout.
   *
   * @param hostname Hostname where the command should execute
   * @param fromFile File path on remote host
   * @param toFile File path to copy to on localhost
   * @param isDir Is the file a directory
   * @return the output of the executed command
   * @throws IOException in various situations:
   *  1. when the executable is not valid, i.e. running ls in Windows
   *  2. execution interrupted
   *  3. other normal reasons for IOException
   */
  public static CommandReturn scpCommandWithOutput(
          String hostname, String fromFile, String toFile, boolean isDir) throws IOException {
    return new ScpCommand(hostname, fromFile, toFile, isDir).runWithOutput();
  }

  /**
   * Executes a shell command.
   * If it fails, try the backup command.
   *
   * @param cmd the primary command
   * @param backupCmd a backup option
   * @return the {@link CommandReturn} with combined output
   */
  public static CommandReturn execCmdWithBackup(ShellCommand cmd, ShellCommand backupCmd)
          throws IOException {
    CommandReturn cr = cmd.runWithOutput();
    // If the command works or there is no backup option, return
    if (cr.getExitCode() == 0 || backupCmd == null) {
      return cr;
    }
    return backupCmd.runWithOutput();
  }

  private ShellUtils() {} // prevent instantiation
}
