/*
 * Copyright 2011 Selenium 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.server.android;

import android.app.Instrumentation;
import android.content.Context;
import android.os.PowerManager;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.MotionEvent.PointerProperties;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.Window;
import android.view.WindowManager;
import android.view.MotionEvent.PointerCoords;
import android.webkit.WebView;
import android.widget.AbsListView;
import android.widget.ScrollView;
import io.selendroid.server.ServerInstrumentation;
import io.selendroid.server.model.TouchScreen;
import io.selendroid.server.model.interactions.Coordinates;
import io.selendroid.server.util.SelendroidLogger;
import io.selendroid.server.android.internal.Point;
import io.selendroid.server.common.action.touch.FlickDirection;
import io.selendroid.server.common.exceptions.SelendroidException;

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;

/**
 * Implements touch capabilities of a device.
 * 
 */
public class AndroidTouchScreen implements TouchScreen {
  private static final int MOTION_EVENT_INJECTION_DELAY_MILLIS = 5;
  private static final int MOTION_EVENT_META_STATE = 0;
  private static final float MOTION_EVENT_X_PRECISION = 1.0f;
  private static final float MOTION_EVENT_Y_PRECISION = 1.0f;
  private static final int MOTION_EVENT_DEVICE_ID = 0;
  private static final int MOTION_EVENT_EDGE_FLAGS = 0;
  private static final int MOTION_EVENT_SOURCE = 0;
  private static final int MOTION_EVENT_FLAGS = 0;
  private final ServerInstrumentation instrumentation;
  private final MotionSender motions;
  private ArrayDeque<Pointer> pointers = new ArrayDeque<Pointer>();

  public AndroidTouchScreen(ServerInstrumentation instrumentation, MotionSender motions) {
    this.instrumentation = instrumentation;
    this.motions = motions;
  }

  public void singleTap(Coordinates where) {
    Point toTap = where.getLocationOnScreen();
    List<MotionEvent> motionEvents = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, toTap));
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_UP, toTap));
    motions.send(motionEvents);
  }

  public void down(int x, int y) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    Point coords = new Point(x, y);
    event.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, coords));
    motions.send(event);
  }

  public void down(int x, int y, int id) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    int action;
    Pointer p = new Pointer(id, x, y);
    if (pointers.isEmpty()) {
      action = MotionEvent.ACTION_DOWN;
      pointers.add(p);
    } else {
      action = MotionEvent.ACTION_POINTER_DOWN;
      pointers.addFirst(p);
    }
    event.add(getMotionEvent(downTime, downTime, action));
    motions.send(event);
  }

  public void up(int x, int y) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    Point coords = new Point(x, y);
    event.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_UP, coords));
    motions.send(event);
  }

  public void up(int x, int y, int id) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    int action;
    if (pointers.size() == 1) {
      action = MotionEvent.ACTION_UP;
    } else {
      action = MotionEvent.ACTION_POINTER_UP;
      movePointerToFront(id);
    }
    event.add(getMotionEvent(downTime, downTime, action));
    motions.send(event);
    pointers.removeFirst();
  }

  private void movePointerToFront(int id) {
    for(Pointer p : pointers) {
      if(p.getId() == id) {
        pointers.remove(p);
        pointers.addFirst(p);
        break;
      }
    }
  }

  public void move(int x, int y) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    Point coords = new Point(x, y);
    event.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_MOVE, coords));
    motions.send(event);
  }

  public void move(int x, int y, int id) {
    List<MotionEvent> event = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    for(Pointer p : pointers) {
      if (p.getId() == id) {
        p.setCoords(x, y);
      }
    }
    event.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_MOVE));
    motions.send(event);
  }

  public void cancel() {
    for(Pointer p : pointers) {
      up((int)p.getCoords().x, (int)p.getCoords().y, p.getId());
    }
  }

  public void scroll(Coordinates where, int xOffset, int yOffset) {
    long downTime = SystemClock.uptimeMillis();
    List<MotionEvent> motionEvents = new ArrayList<MotionEvent>();
    Point origin = where.getLocationOnScreen();
    Point destination = new Point(origin.x + xOffset, origin.y + yOffset);
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, origin));

    Scroll scroll = new Scroll(origin, destination, downTime);
    // Initial acceleration from origin to reference point
    motionEvents.add(getBatchedMotionEvent(downTime, downTime, origin, scroll.getDecelerationPoint(),
        Scroll.INITIAL_STEPS, Scroll.TIME_BETWEEN_EVENTS));
    // Deceleration phase from reference point to destination
    motionEvents.add(getBatchedMotionEvent(downTime, scroll.getEventTimeForReferencePoint(),
            scroll.getDecelerationPoint(), destination, Scroll.DECELERATION_STEPS,
            Scroll.TIME_BETWEEN_EVENTS));

    motionEvents.add(getMotionEvent(downTime,
            (downTime + scroll.getEventTimeForDestinationPoint()), MotionEvent.ACTION_UP, destination));
    motions.send(motionEvents);
  }

  public void doubleTap(Coordinates where) {
    Point toDoubleTap = where.getLocationOnScreen();
    List<MotionEvent> motionEvents = new ArrayList<MotionEvent>();
    long downTime = SystemClock.uptimeMillis();
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, toDoubleTap));
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_UP, toDoubleTap));
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, toDoubleTap));
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_UP, toDoubleTap));
    motions.send(motionEvents);
  }

  public void longPress(Coordinates where) {
    long downTime = SystemClock.uptimeMillis();
    long eventTime = SystemClock.uptimeMillis();
    Point point = where.getLocationOnScreen();
    Instrumentation inst = instrumentation.getInstrumentation();

    MotionEvent event = null;
    boolean isSuccess = false;
    int retry = 0;
    while (!isSuccess && retry < 10) {
      try {
        if (event == null) {
          event =
              MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, point.x, point.y, 0);
        }
        SelendroidLogger.debug("trying to send pointer");
        inst.sendPointerSync(event);
        isSuccess = true;
      } catch (SecurityException e) {
        SelendroidLogger.error("failed: " + retry);
        retry++;
      }
    }
    if (!isSuccess) {
      throw new SelendroidException("Click can not be completed!");
    }
    inst.sendPointerSync(event);
    inst.waitForIdleSync();

    eventTime = SystemClock.uptimeMillis();
    final int touchSlop = ViewConfiguration.get(inst.getTargetContext()).getScaledTouchSlop();
    event =
        MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, point.x + touchSlop / 2,
            point.y + touchSlop / 2, 0);
    inst.sendPointerSync(event);
    inst.waitForIdleSync();

    try {
      Thread.sleep((long) (ViewConfiguration.getLongPressTimeout() * 1.5f));
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }

    eventTime = SystemClock.uptimeMillis();
    event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, point.x, point.y, 0);
    inst.sendPointerSync(event);
    inst.waitForIdleSync();
  }

  public void scroll(final int xOffset, final int yOffset) {
    List<View> scrollableContainer =
        ViewHierarchyAnalyzer.getDefaultInstance().findScrollableContainer();

    if (scrollableContainer == null) {
      // nothing to do
      return;
    }
    for (View view : scrollableContainer) {
      if (view instanceof AbsListView) {
        final AbsListView absListView = (AbsListView) view;
        instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
          public void run() {
            absListView.scrollBy(xOffset, yOffset);
          }
        });
      } else if (view instanceof ScrollView) {
        final ScrollView scrollView = (ScrollView) view;
        instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
          public void run() {
            scrollView.scrollBy(xOffset, yOffset);
          }
        });
      } else if (view instanceof WebView) {
        final WebView webView = (WebView) view;
        instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
          public void run() {
            webView.scrollBy(xOffset, yOffset);
          }
        });
      }
    }
  }

  public void flick(final int speedX, final int speedY) {
    List<View> scrollableContainer =
        ViewHierarchyAnalyzer.getDefaultInstance().findScrollableContainer();

    if (scrollableContainer == null) {
      // nothing to do
      return;
    }
    for (View view : scrollableContainer) {
      if (view instanceof AbsListView) {
        // ignore
      } else if (view instanceof ScrollView) {
        final ScrollView scrollView = (ScrollView) view;
        instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
          public void run() {
            scrollView.fling(speedY);
          }
        });
      } else if (view instanceof WebView) {
        final WebView webView = (WebView) view;
        instrumentation.getCurrentActivity().runOnUiThread(new Runnable() {
          public void run() {
            webView.flingScroll(speedX, speedY);
          }
        });
      }
    }
  }

  public void flick(Coordinates where, int xOffset, int yOffset, int speed) {
    long downTime = SystemClock.uptimeMillis();
    List<MotionEvent> motionEvents = new ArrayList<MotionEvent>();
    Point origin = where.getLocationOnScreen();
    Point destination = new Point(origin.x + xOffset, origin.y + yOffset);
    DynamicIntervalFlick flick = new DynamicIntervalFlick(speed);
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, origin));
    motionEvents.add(getBatchedMotionEvent(downTime, downTime, origin, destination, flick.getNumberOfSteps(),
        flick.getTimeBetweenEvents()));
    motionEvents.add(getMotionEvent(downTime, flick.getTimeForDestinationPoint(downTime),
        MotionEvent.ACTION_UP, destination));
    motions.send(motionEvents);
  }

  public void flick(Point origin, FlickDirection direction, int distance, int duration) {
    int xOffset = distance * direction.getxMultiplier();
    int yOffset = distance * direction.getyMultiplier();
    Point destination = new Point(origin.x + xOffset, origin.y + yOffset);
    generateFlickMotions(origin, destination, new FixedIntervalFlick(duration));
  }

  private void generateFlickMotions(Point origin, Point destination, Flick flick) {
    long downTime = SystemClock.uptimeMillis();

    List<MotionEvent> motionEvents = new ArrayList<MotionEvent>();
    motionEvents.add(getMotionEvent(downTime, downTime, MotionEvent.ACTION_DOWN, origin));
    motionEvents.add(getBatchedMotionEvent(
        downTime, downTime, origin, destination, flick.getNumberOfSteps(),
        flick.getTimeBetweenEvents()));
    motionEvents.add(getMotionEvent(downTime, flick.getTimeForDestinationPoint(downTime),
        MotionEvent.ACTION_UP, destination));
    motions.send(motionEvents);
  }

  private MotionEvent getMotionEvent(long start, long eventTime, int action, Point coords) {
    return MotionEvent.obtain(start, eventTime, action, coords.x, coords.y, 0);
  }

  private MotionEvent getMotionEvent(long start, long eventTime, int action) {
    return MotionEvent.obtain(start, eventTime, action, pointers.size(), getPointerIds(), getPointerCoords(),
            MOTION_EVENT_META_STATE, MOTION_EVENT_X_PRECISION, MOTION_EVENT_Y_PRECISION, MOTION_EVENT_DEVICE_ID,
            MOTION_EVENT_EDGE_FLAGS, MOTION_EVENT_SOURCE, MOTION_EVENT_FLAGS);
  }

  private int[] getPointerIds () {
    int[] pointerIds = new int[pointers.size()];
    int i = 0;
    for(Pointer p : pointers) {
      pointerIds[i++] = p.getId();
    }
    return pointerIds;
  }

  private PointerCoords[] getPointerCoords () {
    PointerCoords[] pointerCoords = new PointerCoords[pointers.size()];
    int i = 0;
    for(Pointer p : pointers) {
      pointerCoords[i++] = p.getCoords();
    }
    return pointerCoords;
  }

  private MotionEvent getBatchedMotionEvent(long downTime, long startingEventTime, Point origin,
                                            Point destination, int steps, long timeBetweenEvents) {
    float xStep = (destination.x - origin.x) / steps;
    float yStep = (destination.y - origin.y) / steps;
    float x = origin.x;
    float y = origin.y;
    long eventTime = startingEventTime;

    x += xStep;
    y += yStep;
    MotionEvent event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_MOVE, x, y, 0);

    for (int i = 0; i < steps - 1; i++) {
      x += xStep;
      y += yStep;

      eventTime += timeBetweenEvents;
      event.addBatch(eventTime, x, y, 1.0f, 1.0f, 0);
    }

    eventTime += timeBetweenEvents;
    event.addBatch(eventTime, destination.getX(), destination.getY(), 1.0f, 1.0f, 0);
    return event;
  }

  @Override
  public float getBrightness() {
    PowerManager powerManager = (PowerManager) instrumentation.getInstrumentation().getContext().getSystemService(Context.POWER_SERVICE);

    if (!powerManager.isScreenOn()) {
      return 0f;
    } else {
      WindowManager.LayoutParams attributes = instrumentation.getCurrentActivity().getWindow().getAttributes();
      return attributes.screenBrightness;
    }
  }

  @Override
  public void setBrightness(float brightness) {
    if (brightness < 0) {
      brightness = 0;
    }
    if (brightness > 1) {
      brightness = 1;
    }

    PowerManager powerManager = (PowerManager) instrumentation.getInstrumentation().getContext().getSystemService(Context.POWER_SERVICE);
    final Window window = instrumentation.getCurrentActivity().getWindow();
    final WindowManager.LayoutParams attributes = window.getAttributes();
    PowerManager.WakeLock wakeLock = null;
    if (brightness != 0) {
      // Turn on display
      if (!powerManager.isScreenOn()) {
        wakeLock = powerManager.newWakeLock(
            PowerManager.ACQUIRE_CAUSES_WAKEUP | PowerManager.ON_AFTER_RELEASE, "Selendroid screen wake");
      }
      // Now set the brightness
      attributes.screenBrightness = brightness;
    } else {
      // Turn off the display. Oh boy. This is derived from a reading of the PowerManager SDK docs.
      // http://developer.android.com/reference/android/os/PowerManager.html
      attributes.screenBrightness = 0;
      wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Selendroid screen sleep");
    }

    instrumentation.getCurrentActivity().runOnUiThread(
        new Runnable() {
          @Override public void run() {
            window.setAttributes(attributes);
          }
        }
    );
    instrumentation.getInstrumentation().waitForIdleSync();

    if (wakeLock != null) {
      try {
        wakeLock.acquire();
        wakeLock.release();
      } catch (SecurityException ignored) {
        // We can only turn off the screen if the AUT has the android.permission.WAKE_LOCK permission.
      }
    }
  }

  final class Scroll {

    private Point origin;
    private Point destination;
    private long downTime;
    // A regular scroll usually has 15 gestures, where the last 5 are used for deceleration
    final static int INITIAL_STEPS = 10;
    final static int DECELERATION_STEPS = 5;
    final int TOTAL_STEPS = INITIAL_STEPS + DECELERATION_STEPS;
    // Time in milliseconds to provide a speed similar to scroll
    final static long TIME_BETWEEN_EVENTS = 50;

    public Scroll(Point origin, Point destination, long downTime) {
      this.origin = origin;
      this.destination = destination;
      this.downTime = downTime;
    }

    // This method is used to calculate the point where the deceleration will start at 20% of the
    // distance to the destination point
    private Point getDecelerationPoint() {
      int deltaX = (destination.x - origin.x);
      int deltaY = (destination.y - origin.y);
      // Coordinates of reference point where deceleration should start for scroll gesture, on the
      // last 20% of the total distance to scroll
      int xRef = (int) (deltaX * 0.8);
      int yRef = (int) (deltaY * 0.8);
      return new Point(origin.x + xRef, origin.y + yRef);
    }

    private long getEventTimeForReferencePoint() {
      return (downTime + INITIAL_STEPS * TIME_BETWEEN_EVENTS);
    }

    private long getEventTimeForDestinationPoint() {
      return (downTime + TOTAL_STEPS * TIME_BETWEEN_EVENTS);
    }
  }

  interface Flick {
    public int getNumberOfSteps();
    public long getTimeBetweenEvents();
    public long getTimeForDestinationPoint(long downTime);
  }

  final class DynamicIntervalFlick implements Flick {

    private final int SPEED_NORMAL = 0;
    private final int SPEED_FAST = 1;
    private final int SPEED_SLOW = 2;

    private int speed;

    public DynamicIntervalFlick(int speed) {
      this.speed = speed;
    }

    public int getNumberOfSteps() {
      return speed == SPEED_SLOW ? 8 : 4;
    }

    public long getTimeBetweenEvents() {
      switch (speed) {
        case SPEED_SLOW:
          return 50;

        case SPEED_NORMAL:
          return 25; // Time in milliseconds to provide a speed similar to normal flick

        case SPEED_FAST:
          return 9;

        default:
          return 0;
      }
    }

    public long getTimeForDestinationPoint(long downTime) {
      return (downTime + getNumberOfSteps() * getTimeBetweenEvents());
    }
  }

  final class FixedIntervalFlick implements Flick {
    private int EVENT_INTERVAL_MS = 15;

    private int time; // Total time in ms of flick gesture

    public FixedIntervalFlick(int time) {
      this.time = time;
    }

    public int getNumberOfSteps() {
      return time / EVENT_INTERVAL_MS;
    }

    public long getTimeBetweenEvents() {
      return (time == 0) ? 0 : EVENT_INTERVAL_MS;
    }

    public long getTimeForDestinationPoint(long downTime) {
      return downTime + time;
    }
  }

  public class Pointer
  {
    private final int id;
    private PointerCoords coords;

    public Pointer(int id, int x, int y)
    {
      this.id   = id;
      coords = new PointerCoords();
      setCoords(x, y);
    }

    public int getId()   {
      return id;
    }

    public PointerCoords getCoords() {
      return coords;
    }

    public void setCoords(int x, int y) {
      coords.x = x;
      coords.y = y;
    }
  }
}
