/*
 * 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.server.model;

import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Strings;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

import io.selendroid.common.SelendroidCapabilities;
import io.selendroid.common.device.DeviceTargetPlatform;
import io.selendroid.server.common.exceptions.SelendroidException;
import io.selendroid.standalone.android.AndroidApp;
import io.selendroid.standalone.android.AndroidDevice;
import io.selendroid.standalone.android.AndroidEmulator;
import io.selendroid.standalone.android.AndroidEmulatorPowerStateListener;
import io.selendroid.standalone.android.DeviceManager;
import io.selendroid.standalone.android.HardwareDeviceListener;
import io.selendroid.standalone.android.impl.DefaultAndroidEmulator;
import io.selendroid.standalone.android.impl.DefaultHardwareDevice;
import io.selendroid.standalone.android.impl.InstalledAndroidApp;
import io.selendroid.standalone.exceptions.AndroidDeviceException;
import io.selendroid.standalone.exceptions.AndroidSdkException;
import io.selendroid.standalone.exceptions.DeviceStoreException;
import io.selendroid.standalone.server.model.impl.DefaultPortFinder;

import org.apache.commons.lang3.StringUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

public class DeviceStore {
  private static final Logger log = Logger.getLogger(DeviceStore.class.getName());
  private List<AndroidDevice> devicesInUse = new ArrayList<AndroidDevice>();
  private Map<DeviceTargetPlatform, List<AndroidDevice>> androidDevices =
      new HashMap<DeviceTargetPlatform, List<AndroidDevice>>();
  private EmulatorPortFinder androidEmulatorPortFinder = null;
  private boolean clearData = true;
  private boolean keepEmulator = false;
  private AndroidEmulatorPowerStateListener emulatorPowerStateListener = null;
  private DeviceManager deviceManager = null;

  public DeviceStore(Integer emulatorPort, DeviceManager deviceManager) {
    this.deviceManager = deviceManager;
    androidEmulatorPortFinder = new DefaultPortFinder(emulatorPort, emulatorPort + 30);
  }

  public DeviceStore(EmulatorPortFinder androidEmulatorPortFinder,
                     DeviceManager deviceManager) {
    this.deviceManager = deviceManager;
    this.androidEmulatorPortFinder = androidEmulatorPortFinder;
  }

  public Integer nextEmulatorPort() {
    return androidEmulatorPortFinder.next();
  }

  /**
   * After a test session a device should be released. That means id will be removed from the list
   * of devices in use and in case of an emulator it will be stopped.
   *
   * @param device The device to release
   * @see {@link #findAndroidDevice(SelendroidCapabilities)}
   */
  public void release(AndroidDevice device, AndroidApp aut) {
    log.info("Releasing device " + device);
    if (devicesInUse.contains(device)) {

      if (aut != null) {
        // stop the app anyway - better in case people do use snapshots
        try {
          device.kill(aut);
        } catch (Exception e) {
          log.log(Level.WARNING, "Failed to kill android application when releasing device", e);
        }

        if (clearData) {
          try {
            device.clearUserData(aut);
          } catch (AndroidSdkException e) {
            log.log(Level.WARNING, "Failed to clear user data of application", e);
          }
        }
      }

      if (device instanceof AndroidEmulator && !(aut instanceof InstalledAndroidApp) && !keepEmulator) {
        AndroidEmulator emulator = (AndroidEmulator) device;
        try {
          emulator.stop();
        } catch (AndroidDeviceException e) {
          log.severe("Failed to stop emulator: " + e.getMessage());
        }
        androidEmulatorPortFinder.release(emulator.getPort());
      }
      devicesInUse.remove(device);
    }
  }

  /* package */void initAndroidDevices(HardwareDeviceListener hardwareDeviceListener,
                                       boolean shouldKeepAdbAlive) throws AndroidDeviceException {
    emulatorPowerStateListener = new DefaultEmulatorPowerStateListener();
    deviceManager.initialize(hardwareDeviceListener, emulatorPowerStateListener);

    List<AndroidEmulator> emulators = DefaultAndroidEmulator.listAvailableAvds();
    addEmulators(emulators);

    if (getDevices().isEmpty()) {
      SelendroidException e =
          new SelendroidException(
              "No android virtual devices were found. "
                  + "Please start the android tool and create emulators and restart the selendroid-standalone "
                  + "or plugin an Android hardware device via USB.");
      log.warning("Warning: " + e);
    }
  }


  public synchronized void addDevice(AndroidDevice androidDevice) throws AndroidDeviceException {
    if (androidDevice == null) {
      log.info("No Android devices were found.");
      return;
    }
    if (androidDevice instanceof AndroidEmulator) {
      throw new AndroidDeviceException(
          "For adding emulator instances please use #addEmulator method.");
    }
    if (androidDevice.isDeviceReady() == true) {
      log.info("Adding: " + androidDevice);
      addDeviceToStore(androidDevice);
    }
  }

  public synchronized void updateDevice(AndroidDevice device) throws AndroidDeviceException {
    boolean deviceRemoved = false;
    for (DeviceTargetPlatform targetPlatform : androidDevices.keySet()) {
      List<AndroidDevice> platformDevices = androidDevices.get(targetPlatform);
      // Attempt to remove the device from this target platform;
      deviceRemoved |= platformDevices.remove(device);
    }

    if (deviceRemoved) {
      addDeviceToStore(device);
    } else {
      log.warning("Attempted to update device which did could not be found in the device store");
    }
  }

  public void addEmulators(List<AndroidEmulator> emulators) throws AndroidDeviceException {
    if (emulators == null || emulators.isEmpty()) {
      log.info("No emulators has been found.");
      return;
    }
    for (AndroidEmulator emulator : emulators) {
      log.info("Adding: " + emulator);
      addDeviceToStore((AndroidDevice) emulator);
    }
  }


  /**
   * Internal method to add an actual device to the store.
   *
   * @param device The device to add.
   * @throws AndroidDeviceException
   */
  protected synchronized void addDeviceToStore(AndroidDevice device) throws AndroidDeviceException {
    if (androidDevices.containsKey(device.getTargetPlatform())) {
      List<AndroidDevice> platformDevices = androidDevices.get(device.getTargetPlatform());
      if (!platformDevices.contains(device)) {
        platformDevices.add(device);
      }
    } else {
      androidDevices.put(device.getTargetPlatform(), Lists.newArrayList(device));
    }
  }

  /**
   * Finds a device for the requested capabilities. <b>important note:</b> if the device is not any
   * longer used, call the {@link #release(AndroidDevice, AndroidApp)} method.
   *
   * @param caps The desired test session capabilities.
   * @return Matching device for a test session.
   * @throws DeviceStoreException
   * @see {@link #release(AndroidDevice, AndroidApp)}
   */
  public synchronized AndroidDevice findAndroidDevice(SelendroidCapabilities caps) throws DeviceStoreException {

    Preconditions.checkArgument(caps != null, "Error: capabilities are null");

    if (androidDevices.isEmpty()) {
      throw new DeviceStoreException("Fatal Error: Device Store does not contain any Android Device.");
    }

    String platformVersion = caps.getPlatformVersion();

    Iterable<AndroidDevice> candidateDevices = Strings.isNullOrEmpty(platformVersion) ?
        Iterables.concat(androidDevices.values()) : androidDevices.get(DeviceTargetPlatform.fromPlatformVersion(platformVersion));

    candidateDevices = MoreObjects.firstNonNull(candidateDevices, Collections.EMPTY_LIST);

    FluentIterable<AndroidDevice> allMatchingDevices = FluentIterable.from(candidateDevices)
        .filter(deviceNotInUse())
        .filter(deviceSatisfiesCapabilities(caps));

    if (!allMatchingDevices.isEmpty()) {

      AndroidDevice matchingDevice = allMatchingDevices.filter(deviceRunning()).first()
          .or(allMatchingDevices.first()).get();

      if (!deviceRunning().apply(matchingDevice)) {
        log.info("Using potential match: " + matchingDevice);
      }

      devicesInUse.add(matchingDevice);
      return matchingDevice;

    } else {
      throw new DeviceStoreException("No devices are found. "
          + "This can happen if the devices are in use or no device screen "
          + "matches the required capabilities.");
    }
  }

  private boolean isEmulatorSwitchedOff(AndroidDevice device) throws DeviceStoreException {
    if (device instanceof AndroidEmulator) {
      try {
        return !((AndroidEmulator) device).isEmulatorStarted();
      } catch (AndroidDeviceException e) {
        throw new DeviceStoreException(e);
      }
    }
    return true;
  }

  public List<AndroidDevice> getDevices() {
    List<AndroidDevice> devices = new ArrayList<AndroidDevice>();
    for (Map.Entry<DeviceTargetPlatform, List<AndroidDevice>> entry : androidDevices.entrySet()) {
      devices.addAll(entry.getValue());
    }
    return devices;
  }

  /**
   * For testing only
   */
  /* package */List<AndroidDevice> getDevicesInUse() {
    return devicesInUse;
  }

  /**
   * For testing only
   */
  /* package */Map<DeviceTargetPlatform, List<AndroidDevice>> getDevicesList() {
    return androidDevices;
  }

  /**
   * Removes the given device from store so that it cannot be any longer be used for testing. This
   * can happen if e.g. the hardware device gets unplugged from the computer.
   *
   * @param device the device to remove.
   * @throws DeviceStoreException when parameter is not type of 'DefaultHardwareDevice'.
   */
  public void removeAndroidDevice(AndroidDevice device) throws DeviceStoreException {
    if (device == null) {
      return;
    }
    boolean hardwareDevice = device instanceof DefaultHardwareDevice;
    if (hardwareDevice == false) {
      throw new DeviceStoreException("Only devices of type 'DefaultHardwareDevice' can be removed.");
    }

    release(device, null);
    DeviceTargetPlatform apiLevel = device.getTargetPlatform();
    if (androidDevices.containsKey(apiLevel)) {
      log.info("Removing: " + device);
      androidDevices.get(apiLevel).remove(device);
      if (androidDevices.get(apiLevel).isEmpty()) {
        androidDevices.remove(apiLevel);
      }
    } else {
      for (List<AndroidDevice> targetDevices : androidDevices.values()) {
        if (targetDevices.contains(device)) {
          log.warning("Device in devicestore");
        }
      }
      log.warning("The target platform version of the device is not found in device store.");
      log.warning("The device was propably already removed.");
    }
  }

  public void setClearData(boolean clearData) {
    this.clearData = clearData;
  }

  public void setKeepEmulator(boolean keepEmulator) {
    this.keepEmulator = keepEmulator;
  }

  private Predicate<AndroidDevice> deviceNotInUse() {
    return new Predicate<AndroidDevice>() {
      @Override
      public boolean apply(AndroidDevice candidate) {
        return !devicesInUse.contains(candidate);
      }
    };
  }

  private Predicate<AndroidDevice> deviceSatisfiesCapabilities(final SelendroidCapabilities capabilities) {
    return new Predicate<AndroidDevice>() {
      @Override
      public boolean apply(AndroidDevice candidate) {
        ArrayList<Boolean> booleanExpressions = Lists.newArrayList(
            candidate.screenSizeMatches(capabilities.getScreenSize()),
            capabilities.getEmulator() == null ? true : capabilities.getEmulator() ?
                candidate instanceof DefaultAndroidEmulator : candidate instanceof DefaultHardwareDevice,
            StringUtils.isNotBlank(capabilities.getSerial()) ? capabilities.getSerial().equals(candidate.getSerial()) : true,
            StringUtils.isNotBlank(capabilities.getModel()) ? candidate.getModel().contains(capabilities.getModel()) : true,
            StringUtils.isNotBlank(capabilities.getAPITargetType()) ? candidate.getAPITargetType() != null
                && candidate.getAPITargetType().contains(capabilities.getAPITargetType()) : true
        );

        return Iterables.all(booleanExpressions, Predicates.equalTo(true));
      }
    };
  }

  private Predicate<AndroidDevice> deviceRunning() {
    return new Predicate<AndroidDevice>() {
      @Override
      public boolean apply(AndroidDevice candidate) {
        return !(candidate instanceof DefaultAndroidEmulator && !((DefaultAndroidEmulator) candidate).isEmulatorStarted());
      }
    };
  }

  class DefaultEmulatorPowerStateListener implements AndroidEmulatorPowerStateListener {

    @Override
    public void onDeviceStarted(String avdName, String serial) {
      AndroidEmulator emulator = findEmulator(avdName);
      if (emulator != null) {
        Integer port = Integer.parseInt(serial.replace("emulator-", ""));
        emulator.setSerial(port);
        emulator.setWasStartedBySelendroid(false);
      }
    }

    AndroidEmulator findEmulator(String avdName) {
      for (AndroidDevice device : getDevices()) {
        if (device instanceof AndroidEmulator) {
          AndroidEmulator emulator = (AndroidEmulator) device;
          if (avdName.equals(emulator.getAvdName())) {
            return emulator;
          }
        }
      }
      return null;
    }

    @Override
    public void onDeviceStopped(String avdName) {
      // do nothing
    }
  }
}
