/*
 * Copyright 2012-2014 eBay Software Foundation and selendroid committers.
 *
 * Licensed 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 io.selendroid.standalone.android.impl;

import com.android.ddmlib.AdbCommandRejectedException;
import com.android.ddmlib.IDevice;
import com.android.ddmlib.RawImage;
import com.android.ddmlib.TimeoutException;
import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import com.google.common.collect.ObjectArrays;
import io.selendroid.common.SelendroidCapabilities;
import io.selendroid.server.common.exceptions.SelendroidException;
import io.selendroid.server.common.model.ExternalStorageFile;
import io.selendroid.server.common.utils.SelendroidArguments;
import io.selendroid.standalone.android.AndroidApp;
import io.selendroid.standalone.android.AndroidDevice;
import io.selendroid.standalone.android.AndroidSdk;
import io.selendroid.standalone.android.InstrumentationProcessListener;
import io.selendroid.standalone.exceptions.AndroidDeviceException;
import io.selendroid.standalone.exceptions.AndroidSdkException;
import io.selendroid.standalone.exceptions.ShellCommandException;
import io.selendroid.standalone.io.ShellCommand;
import org.apache.commons.exec.*;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.impl.client.HttpClientBuilder;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.logging.LogEntry;
import org.json.JSONException;
import org.json.JSONObject;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public abstract class AbstractDevice implements AndroidDevice {
  private static final Logger log = Logger.getLogger(AbstractDevice.class.getName());
  public static final String WD_STATUS_ENDPOINT = "http://localhost:8080/wd/hub/status";
  public static final int MAX_ADB_COMMAND_LENGTH = 1024;
  protected String serial = null;
  protected String model = null;
  protected String apiTargetType = "android";
  protected IDevice device;
  private ByteArrayOutputStream logoutput;
  private ExecuteWatchdog logcatWatchdog;
  private static final Integer COMMAND_TIMEOUT = 20000;
  private boolean loggingEnabled = true;
  private final List<InstrumentationProcessListener> instrumentationProcessListeners = new ArrayList();

  // Port that we forward locally to the device
  protected int localPort;
  // Port that's used inside the device
  protected int remotePort;

  /**
   * Constructor meant to be used with Android Emulators because a reference to the {@link IDevice}
   * will become available if the emulator will be started. Please make sure that #setIDevice is
   * called on the emulator.
   *
   * @param serial
   */
  public AbstractDevice(String serial) {
    this.serial = serial;
  }

  /**
   * Constructor mean to be used with Android Hardware devices because a reference to the
   * {@link IDevice} will be available immediately after they are connected.
   *
   * @param device
   */
  public AbstractDevice(IDevice device) {
    this.device = device;
    this.serial = device.getSerialNumber();
  }

  protected AbstractDevice() {}

  protected boolean isSerialConfigured() {
    return serial != null && !serial.isEmpty();
  }

  public void setVerbose() {
    log.setLevel(Level.FINEST);
  }

  public void addInstrumentationProcessListener(
    InstrumentationProcessListener listener) {
    instrumentationProcessListeners.add(listener);
  }

  @Override
  public boolean isDeviceReady() {
    CommandLine command = adbCommand("shell", "getprop init.svc.bootanim");
    String bootAnimDisplayed = null;
    try {
      bootAnimDisplayed = ShellCommand.exec(command);
    } catch (ShellCommandException e) {
      log.log(Level.INFO, "Could not get property init.svc.bootanim", e);
    }
    return bootAnimDisplayed != null && bootAnimDisplayed.contains("stopped");
  }

  @Override
  public boolean isInstalled(String appBasePackage) throws AndroidSdkException {
    CommandLine command = adbCommand("shell", "pm", "list", "packages");

    command.addArgument(appBasePackage, false);
    String result = null;
    try {
      result = ShellCommand.exec(command);
    } catch (ShellCommandException e) {}

    return result != null && result.contains("package:" + appBasePackage);
  }

  @Override
  public boolean isInstalled(AndroidApp app) throws AndroidSdkException {
    return isInstalled(app.getBasePackage());
  }

  @Override
  public void install(AndroidApp app) throws AndroidSdkException {
    // Uninstall if already installed
    if (isInstalled(app)) {
      uninstall(app);
    }
    // -r: replace existing application
    // -d: allow version code downgrade
    CommandLine command = adbCommand("install", "-r", "-d", app.getAbsolutePath());

    String out = executeCommandQuietly(command, COMMAND_TIMEOUT * 6);
    try {
      // give it a second to recover from the install
      Thread.sleep(1000);
    } catch (InterruptedException ie) {
      throw new RuntimeException(ie);
    }
    if (!out.contains("Success")) {
      throw new AndroidSdkException("APK installation failed. Output:\n" + out);
    }
  }

  public boolean start(AndroidApp app) throws AndroidSdkException {
    if (!isInstalled(app)) {
      install(app);
    }

    String mainActivity = app.getMainActivity().replace(app.getBasePackage(), "");
    CommandLine command =
        adbCommand("shell", "am", "start", "-a", "android.intent.action.MAIN", "-n",
            app.getBasePackage() + "/" + mainActivity);

    String out = executeCommandQuietly(command);
    try {
      // give it a second to recover from the activity start
      Thread.sleep(1000);
    } catch (InterruptedException ie) {
      throw new RuntimeException(ie);
    }
    return out.contains("Starting: Intent");
  }

  protected String executeCommandQuietly(CommandLine command) {
    return executeCommandQuietly(command, COMMAND_TIMEOUT);
  }

  protected String executeCommandQuietly(CommandLine command, long timeout) {
    try {
      return ShellCommand.exec(command, timeout);
    } catch (ShellCommandException e) {
      String logMessage = String.format("Could not execute command: %s", command);
      log.log(Level.WARNING, logMessage, e);
      return "";
    }
  }

  @Override
  public void uninstall(AndroidApp app) throws AndroidSdkException {
    CommandLine command = adbCommand("uninstall", app.getBasePackage());

    executeCommandQuietly(command);
    try {
      // give it a second to recover from the uninstall
      Thread.sleep(1000);
    } catch (InterruptedException ie) {
      throw new RuntimeException(ie);
    }
  }

  @Override
  public void clearUserData(AndroidApp app) throws AndroidSdkException {
    CommandLine command = adbCommand("shell", "pm", "clear", app.getBasePackage());
    executeCommandQuietly(command);
  }

  @Override
  public void kill(AndroidApp aut) throws AndroidDeviceException, AndroidSdkException {
    try {
      CommandLine command = adbCommand("shell", "am", "force-stop", aut.getBasePackage());
      executeCommandQuietly(command);
    } finally {
      killProcesses(aut.getBasePackage());
      freeSelendroidPort();
    }

    if (logcatWatchdog != null && logcatWatchdog.isWatching()) {
      logcatWatchdog.destroyProcess();
      logcatWatchdog = null;
    }
  }

  private void killProcesses(String packageName) {
    CommandLine command = adbCommand("shell", "ps");
    String processes = "";
    try {
      processes = ShellCommand.exec(command);
    } catch (ShellCommandException e) {
      String logMessage = String.format("Could not execute command: %s", command);
      log.log(Level.WARNING, logMessage, e);
    }

    for (String process: processes.split("\\r\\n|\\r|\\n")) {
      if (process.endsWith(packageName)) {
        String pid = process.split("\\s+")[1];
        command = adbCommand("shell", "run-as", packageName, "kill", pid);
        executeCommandQuietly(command);
      }
    }
  }

  private void freeSelendroidPort() {
  CommandLine command = adbCommand("forward", "--remove", "tcp:" + localPort);
    try {
      ShellCommand.exec(command, 20000);
    } catch (ShellCommandException e) {
      log.log(Level.WARNING, "Could not free Selendroid port", e);
    }
  }

  @Override
  public void startSelendroid(
    AndroidApp aut,
    int port,
    SelendroidCapabilities capabilities) throws AndroidSdkException {
    remotePort = port;

    List<String> argList = Lists.newArrayList(
        "-e", SelendroidArguments.MAIN_ACTIVITY, aut.getMainActivity(),
        "-e", SelendroidArguments.SERVER_PORT, Integer.toString(port));

    if (capabilities.getUseJUnitBootstrap()) {
      argList.addAll(Lists.newArrayList(
        "-e", "timeout_msec", "0", // No timeout for the looper thread
        "-e", "disableAnalytics", "true")); // AndroidJUnitRunner sends things to Google Analytics by default
    }
    if (capabilities.getSelendroidExtensions() != null) {
      argList.addAll(Lists.newArrayList("-e", SelendroidArguments.LOAD_EXTENSIONS, "true"));
      if (capabilities.getBootstrapClassNames() != null) {
        argList.addAll(Lists.newArrayList("-e", SelendroidArguments.BOOTSTRAP, capabilities.getBootstrapClassNames()));
      }
    }

    if (capabilities.getUseJUnitBootstrap()) {
      argList.add("io.selendroid." + aut.getBasePackage() + "/io.selendroid.server.JUnitRunnerInstrumentation");
    } else {
      argList.add("io.selendroid." + aut.getBasePackage() + "/io.selendroid.server.SelendroidInstrumentation");
    }

    runInstrumentCommand(argList.toArray(new String[argList.size()]));

    localPort = forwardPort(capabilities.getUseRandomLocalPort() ? 0 : port, port);

    if(isLoggingEnabled()) {
      startLogging();
    }
  }

  private void runInstrumentCommand(String[] args) {
    CommandLine command = adbCommand(ObjectArrays.concat(new String[]{"shell", "am", "instrument", "-w"}, args, String.class));

    try {
      ShellCommand.execAsync(null, command, new ShellCommand.Listener() {
        @Override
        public void onCommandExecutionFinished(int exitCode, String output) {
          for (InstrumentationProcessListener listener : instrumentationProcessListeners) {
            listener.onInstrumentationProcessComplete(output);
          }
        }

        @Override
        public void onCommandExecutionFailed(Exception error, String partialOutput) {
          for (InstrumentationProcessListener listener : instrumentationProcessListeners) {
            listener.onInstrumentationProcessFailed(partialOutput, error);
          }
        }
      });
    } catch(Exception e) {
      throw new SelendroidException("Failed to execute instrument command", e);
    }
  }

  public int forwardPort(int local, int remote) {
    CommandLine command = adbCommand("forward", "tcp:" + local, "tcp:" + remote);
    try {
      String portStr = ShellCommand.exec(command);
      return local == 0 ? Integer.parseInt(portStr) : local;
    } catch (ShellCommandException forwardException) {
      String debugForwardList;
      try {
        debugForwardList = ShellCommand.exec(adbCommand("forward", "--list"), 10000);
      } catch (ShellCommandException listException) {
        debugForwardList = "Could not get list of forwarded ports.";
      }

      throw new SelendroidException(
          "Could not forward port: " + command + "\nList of forwarded ports:\n" + debugForwardList,
          forwardException);
    }
  }

  @Override
  public boolean isSelendroidRunning() {
    HttpClient httpClient = HttpClientBuilder.create().build();
    String url = WD_STATUS_ENDPOINT.replace("8080", String.valueOf(localPort));
    log.info("Checking if the Selendroid server is running: " + url);
    HttpRequestBase request = new HttpGet(url);
    HttpResponse response;
    try {
      response = httpClient.execute(request);
    } catch (Exception e) {
      log.log(
        Level.INFO,
        "Can't connect to Selendroid server, assuming it is not running.",
        e);
      return false;
    }

    int statusCode = response.getStatusLine().getStatusCode();
    log.info("Got response status code: " + statusCode);
    String responseValue;
    try {
      responseValue = IOUtils.toString(response.getEntity().getContent());
      log.info("Got response value: " + responseValue);
    } catch (Exception e) {
      log.log(Level.INFO, "Error reading response from selendroid-server", e);
      log.info("Assuming server has not started");
      return false;
    }

    return statusCode == 200 && responseValue.contains("selendroid");
  }

  @Override
  public int getSelendroidsPort() {
    return localPort;
  }

  @Override
  public List<LogEntry> getLogs() {
    List<LogEntry> logs = Lists.newArrayList();
    String result = logoutput != null ? logoutput.toString() : "";
    String[] lines = result.split("\\r?\\n");
    log.fine("getting logcat");
    for (String line : lines) {
      Level l;
      if (line.startsWith("I")) {
        l = Level.INFO;
      } else if (line.startsWith("W")) {
        l = Level.WARNING;
      } else if (line.startsWith("S")) {
        l = Level.SEVERE;
      } else {
        l = Level.FINE;
      }
      logs.add(new LogEntry(l, System.currentTimeMillis(), line));
      log.fine(line);
    }
    return logs;
  }

  @Override
  public boolean isLoggingEnabled() {
    return loggingEnabled;
  }

  @Override
  public void setLoggingEnabled(boolean loggingEnabled) {
    this.loggingEnabled = loggingEnabled;
  }

  private void startLogging() {
    logoutput = new ByteArrayOutputStream();
    DefaultExecutor exec = new DefaultExecutor();
    exec.setStreamHandler(new PumpStreamHandler(logoutput));
    CommandLine command = adbCommand("logcat", "ResourceType:S", "dalvikvm:S", "Trace:S", "SurfaceFlinger:S",
        "StrictMode:S", "ExchangeService:S", "SVGAndroid:S", "skia:S", "LoaderManager:S", "ActivityThread:S", "-v", "time");
    log.info("starting logcat:");
    log.fine(command.toString());
    try {
      exec.execute(command, new DefaultExecuteResultHandler());
      logcatWatchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT);
      exec.setWatchdog(logcatWatchdog);
    } catch (IOException e) {
      log.log(Level.SEVERE, e.getMessage(), e);
    }
  }

  protected String getProp(String key) {
    CommandLine command = adbCommand("shell", "getprop", key);
    String prop = executeCommandQuietly(command);

    return prop == null ? "" : prop.replace("\r", "").replace("\n", "");
  }

  protected static String extractValue(String regex, String output) {
    Pattern pattern = Pattern.compile(regex, Pattern.MULTILINE);
    Matcher matcher = pattern.matcher(output);
    if (matcher.find()) {
      return matcher.group(1);
    }

    return "";
  }

  public boolean screenSizeMatches(String requestedScreenSize) {
    // if screen size is not requested, just ignore it
    if (requestedScreenSize == null || requestedScreenSize.isEmpty()) {
      return true;
    }

    Pattern dimensionPattern = Pattern.compile("([0-9]+)x([0-9]+)");
    Matcher dimensionMatcher = dimensionPattern.matcher(requestedScreenSize);
    if (dimensionMatcher.matches()) {
      int width = Integer.parseInt(dimensionMatcher.group(1));
      int height = Integer.parseInt(dimensionMatcher.group(2));
      return getScreenSize().equals(new Dimension(width, height));
    } else {
      return false;
    }
  }

  public String runAdbCommand(String parameter) {
    if (parameter == null || parameter.isEmpty()) {
      return null;
    }
    log.fine("running command: adb " + parameter);
    CommandLine command = adbCommand();

    String[] params = parameter.split(" ");
    for (String param : params) {
      command.addArgument(param, false);
    }

    String commandOutput = executeCommandQuietly(command);
    return commandOutput.trim();
  }

  public byte[] takeScreenshot() throws AndroidDeviceException {
    if (device == null) {
      throw new AndroidDeviceException("Device not accessible via ddmlib.");
    }
    RawImage rawImage;
    try {
      rawImage = device.getScreenshot();
    } catch (IOException ioe) {
      throw new AndroidDeviceException("Unable to get frame buffer: " + ioe.getMessage());
    } catch (TimeoutException e) {
      log.log(Level.SEVERE, e.getMessage(), e);
      throw new AndroidDeviceException(e.getMessage());
    } catch (AdbCommandRejectedException e) {
      log.log(Level.SEVERE, e.getMessage(), e);
      throw new AndroidDeviceException(e.getMessage());
    }

    // device/adb not available?
    if (rawImage == null) return null;

    BufferedImage image =
        new BufferedImage(rawImage.width, rawImage.height, BufferedImage.TYPE_3BYTE_BGR);

    int index = 0;
    int IndexInc = rawImage.bpp >> 3;
    for (int y = 0; y < rawImage.height; y++) {
      for (int x = 0; x < rawImage.width; x++) {
        image.setRGB(x, y, rawImage.getARGB(index));
        index += IndexInc;
      }
    }
    return toByteArray(image);
  }

  protected byte[] toByteArray(BufferedImage image) throws AndroidDeviceException {
    ByteArrayOutputStream stream = new ByteArrayOutputStream();

    try {
      if (!ImageIO.write(image, "png", stream)) {
        throw new IOException("Failed to find png writer");
      }
    } catch (IOException e) {
      log.log(Level.SEVERE, "Cannot take screenshot", e);
      throw new AndroidDeviceException(e.getMessage());
    }
    byte[] raw = null;
    try {
      stream.flush();
      raw = stream.toByteArray();
      stream.close();
    } catch (IOException e) {
      throw new RuntimeException("I/O Error while capturing screenshot: " + e.getMessage());
    } finally {
      try {
        stream.close();
      } catch (IOException ioe) {
        // ignore
      }
    }
    return raw;
  }

  /**
   * Use adb to send a keyevent to the device.
   *
   * Full list of keys available here:
   * http://developer.android.com/reference/android/view/KeyEvent.html
   *
   * @param value - Key to be sent to 'adb shell input keyevent'
   */
  public void inputKeyevent(int value) {
    executeCommandQuietly(adbCommand("shell", "input", "keyevent", "" + value));
    // need to wait a beat for the UI to respond
    sleep(500);
  }

  public void invokeActivity(String activity) {
    executeCommandQuietly(adbCommand("shell", "am", "start", "-a", activity));
    // need to wait a beat for the UI to respond
    sleep(500);
  }

  public void restartADB() {
    executeCommandQuietly(adbCommand("kill-server"));
    sleep(500);
    // make sure it's backup again
    executeCommandQuietly(adbCommand("devices"));
  }

  private CommandLine adbCommand() {
    CommandLine command = new CommandLine(AndroidSdk.adb());
    if (isSerialConfigured()) {
      command.addArgument("-s", false);
      command.addArgument(serial, false);
    }
    return command;
  }

  private CommandLine adbCommand(String... args) {
    CommandLine command = adbCommand();
    for (String arg : args) {
      command.addArgument(arg, false);
    }
    String commandString = command.toString();
    if (commandString != null && commandString.length() > MAX_ADB_COMMAND_LENGTH) {
      throw new RuntimeException("Adb command must be under " + MAX_ADB_COMMAND_LENGTH);
    }
    return command;
  }

  public String getExternalStoragePath() {
    return runAdbCommand("shell echo $EXTERNAL_STORAGE");
  }

  /** {@inheritdoc} */
  public String getCrashLog() {
    String crashLogFileName = ExternalStorageFile.APP_CRASH_LOG.toString();

    // The "test" utility doesn't exist on all devices so we'll check the output of ls.
    String crashLogDirPath = getExternalStoragePath();
    if (!crashLogDirPath.endsWith("/")) {
      crashLogDirPath += "/";  // Make sure it ends with '/' so we're listing directory contents.
    }
    String directoryList = executeCommandQuietly(adbCommand("shell", "ls", crashLogDirPath));
    if (directoryList.contains(crashLogFileName)) {
      return executeCommandQuietly(adbCommand("shell", "cat", crashLogDirPath + crashLogFileName));
    }

    return "";
  }

  /** {@inheritDoc} */
  public String listRunningThirdPartyProcesses() {
    String psOutput = runAdbCommand("shell ps");
    StringBuilder sb = new StringBuilder();
    boolean isFirstHeaderLine = true;
    for (String line: Splitter.on("\n").split(psOutput)) {
      boolean isThirdPartyProcess = line.contains(".") && !line.contains("com.android");
      if (isFirstHeaderLine || isThirdPartyProcess) {
        sb.append(line + "\n");
      }
      isFirstHeaderLine = false;
    }
    return sb.toString();
  }

  private void sleep(int millis) {
    try {
      Thread.sleep(millis);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      log.log(Level.SEVERE, e.getMessage(), e);
    }
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass() || device == null) return false;

    AbstractDevice that = (AbstractDevice) o;

    return device.equals(that.device);
  }

  @Override
  public int hashCode() {
    return device.hashCode();
  }

  public String getModel() {
    return model;
  }

  public String getAPITargetType() {
    return apiTargetType;
  }
}
