/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.ui;

import org.mozilla.gecko.gfx.PointUtils;
import org.json.JSONException;
import android.graphics.PointF;
import android.util.Log;
import android.view.MotionEvent;
import java.util.LinkedList;
import java.util.ListIterator;
import java.util.Stack;

/**
 * A less buggy, and smoother, replacement for the built-in Android ScaleGestureDetector.
 *
 * This gesture detector is more reliable than the built-in ScaleGestureDetector because:
 *
 *   - It doesn't assume that pointer IDs are numbered 0 and 1.
 *
 *   - It doesn't attempt to correct for "slop" when resting one's hand on the device. On some
 *     devices (e.g. the Droid X) this can cause the ScaleGestureDetector to lose track of how many
 *     pointers are down, with disastrous results (bug 706684).
 *
 *   - Cancelling a zoom into a pan is handled correctly.
 *
 *   - Starting with three or more fingers down, releasing fingers so that only two are down, and
 *     then performing a scale gesture is handled correctly.
 *
 *   - It doesn't take pressure into account, which results in smoother scaling.
 */
public class SimpleScaleGestureDetector {
    private static final String LOGTAG = "GeckoSimpleScaleGestureDetector";

    private SimpleScaleGestureListener mListener;
    private long mLastEventTime;

    /* Information about all pointers that are down. */
    private LinkedList<PointerInfo> mPointerInfo;

    /** Creates a new gesture detector with the given listener. */
    public SimpleScaleGestureDetector(SimpleScaleGestureListener listener) {
        mListener = listener;
        mPointerInfo = new LinkedList<PointerInfo>();
    }

    /** Forward touch events to this function. */
    public void onTouchEvent(MotionEvent event) {
        switch (event.getAction() & MotionEvent.ACTION_MASK) {
        case MotionEvent.ACTION_DOWN:
        case MotionEvent.ACTION_POINTER_DOWN:
            onTouchStart(event);
            break;
        case MotionEvent.ACTION_MOVE:
            onTouchMove(event);
            break;
        case MotionEvent.ACTION_POINTER_UP:
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            onTouchEnd(event);
            break;
        }
    }

    private int getPointersDown() {
        return mPointerInfo.size();
    }

    private int getActionIndex(MotionEvent event) {
        return (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK)
            >> MotionEvent.ACTION_POINTER_INDEX_SHIFT;
    }

    private void onTouchStart(MotionEvent event) {
        mLastEventTime = event.getEventTime();
        mPointerInfo.addFirst(PointerInfo.create(event, getActionIndex(event)));
        if (getPointersDown() == 2) {
            sendScaleGesture(EventType.BEGIN);
        }
    }

    private void onTouchMove(MotionEvent event) {
        mLastEventTime = event.getEventTime();
        for (int i = 0; i < event.getPointerCount(); i++) {
            PointerInfo pointerInfo = pointerInfoForEventIndex(event, i);
            if (pointerInfo != null) {
                pointerInfo.populate(event, i);
            }
        }

        if (getPointersDown() == 2) {
            sendScaleGesture(EventType.CONTINUE);
        }
    }

    private void onTouchEnd(MotionEvent event) {
        mLastEventTime = event.getEventTime();

        boolean isCancel = (event.getAction() & MotionEvent.ACTION_MASK) == MotionEvent.ACTION_CANCEL;
        int id = event.getPointerId(getActionIndex(event));
        ListIterator<PointerInfo> iterator = mPointerInfo.listIterator();
        while (iterator.hasNext()) {
            PointerInfo pointerInfo = iterator.next();
            if (!(isCancel || pointerInfo.getId() == id)) {
                continue;
            }

            // One of the pointers we were tracking was lifted. Remove its info object from the
            // list, recycle it to avoid GC pauses, and send an onScaleEnd() notification if this
            // ended the gesture.
            iterator.remove();
            pointerInfo.recycle();
            if (getPointersDown() == 1) {
                sendScaleGesture(EventType.END);
            }
        }
    }

    /**
     * Returns the X coordinate of the focus location (the midpoint of the two fingers). If only
     * one finger is down, returns the location of that finger.
     */
    public float getFocusX() {
        switch (getPointersDown()) {
        case 1:
            return mPointerInfo.getFirst().getCurrent().x;
        case 2:
            PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
            return (pointerA.getCurrent().x + pointerB.getCurrent().x) / 2.0f;
        }

        Log.e(LOGTAG, "No gesture taking place in getFocusX()!");
        return 0.0f;
    }

    /**
     * Returns the Y coordinate of the focus location (the midpoint of the two fingers). If only
     * one finger is down, returns the location of that finger.
     */
    public float getFocusY() {
        switch (getPointersDown()) {
        case 1:
            return mPointerInfo.getFirst().getCurrent().y;
        case 2:
            PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
            return (pointerA.getCurrent().y + pointerB.getCurrent().y) / 2.0f;
        }

        Log.e(LOGTAG, "No gesture taking place in getFocusY()!");
        return 0.0f;
    }

    /** Returns the most recent distance between the two pointers. */
    public float getCurrentSpan() {
        if (getPointersDown() != 2) {
            Log.e(LOGTAG, "No gesture taking place in getCurrentSpan()!");
            return 0.0f;
        }

        PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
        return PointUtils.distance(pointerA.getCurrent(), pointerB.getCurrent());
    }

    /** Returns the second most recent distance between the two pointers. */
    public float getPreviousSpan() {
        if (getPointersDown() != 2) {
            Log.e(LOGTAG, "No gesture taking place in getPreviousSpan()!");
            return 0.0f;
        }

        PointerInfo pointerA = mPointerInfo.getFirst(), pointerB = mPointerInfo.getLast();
        PointF a = pointerA.getPrevious(), b = pointerB.getPrevious();
        if (a == null || b == null) {
            a = pointerA.getCurrent();
            b = pointerB.getCurrent();
        }

        return PointUtils.distance(a, b);
    }

    /** Returns the time of the last event related to the gesture. */
    public long getEventTime() {
        return mLastEventTime;
    }

    /** Returns true if the scale gesture is in progress and false otherwise. */
    public boolean isInProgress() {
        return getPointersDown() == 2;
    }

    /* Sends the requested scale gesture notification to the listener. */
    private void sendScaleGesture(EventType eventType) {
        switch (eventType) {
        case BEGIN:     mListener.onScaleBegin(this);   break;
        case CONTINUE:  mListener.onScale(this);        break;
        case END:       mListener.onScaleEnd(this);     break;
        }
    }

    /*
     * Returns the pointer info corresponding to the given pointer index, or null if the pointer
     * isn't one that's being tracked.
     */
    private PointerInfo pointerInfoForEventIndex(MotionEvent event, int index) {
        int id = event.getPointerId(index);
        for (PointerInfo pointerInfo : mPointerInfo) {
            if (pointerInfo.getId() == id) {
                return pointerInfo;
            }
        }
        return null;
    }

    private enum EventType {
        BEGIN,
        CONTINUE,
        END,
    }

    /* Encapsulates information about one of the two fingers involved in the gesture. */
    private static class PointerInfo {
        /* A free list that recycles pointer info objects, to reduce GC pauses. */
        private static Stack<PointerInfo> sPointerInfoFreeList;

        private int mId;
        private PointF mCurrent, mPrevious;

        private PointerInfo() {
            // External users should use create() instead.
        }

        /* Creates or recycles a new PointerInfo instance from an event and a pointer index. */
        public static PointerInfo create(MotionEvent event, int index) {
            if (sPointerInfoFreeList == null) {
                sPointerInfoFreeList = new Stack<PointerInfo>();
            }

            PointerInfo pointerInfo;
            if (sPointerInfoFreeList.empty()) {
                pointerInfo = new PointerInfo();
            } else {
                pointerInfo = sPointerInfoFreeList.pop();
            }

            pointerInfo.populate(event, index);
            return pointerInfo;
        }

        /*
         * Fills in the fields of this instance from the given motion event and pointer index
         * within that event.
         */
        public void populate(MotionEvent event, int index) {
            mId = event.getPointerId(index);
            mPrevious = mCurrent;
            mCurrent = new PointF(event.getX(index), event.getY(index));
        }

        public void recycle() {
            mId = -1;
            mPrevious = mCurrent = null;
            sPointerInfoFreeList.push(this);
        }

        public int getId() { return mId; }
        public PointF getCurrent() { return mCurrent; }
        public PointF getPrevious() { return mPrevious; }

        @Override
        public String toString() {
            if (mId == -1) {
                return "(up)";
            }

            try {
                String prevString;
                if (mPrevious == null) {
                    prevString = "n/a";
                } else {
                    prevString = PointUtils.toJSON(mPrevious).toString();
                }

                // The current position should always be non-null.
                String currentString = PointUtils.toJSON(mCurrent).toString();
                return "id=" + mId + " cur=" + currentString + " prev=" + prevString;
            } catch (JSONException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public static interface SimpleScaleGestureListener {
        public boolean onScale(SimpleScaleGestureDetector detector);
        public boolean onScaleBegin(SimpleScaleGestureDetector detector);
        public void onScaleEnd(SimpleScaleGestureDetector detector);
    }
}

