/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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;

import java.util.ArrayList;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;

import org.mozilla.gecko.AppConstants.Versions;
import org.mozilla.gecko.annotation.WrapForJNI;
import org.mozilla.gecko.util.GamepadUtils;
import org.mozilla.gecko.util.ThreadUtils;

import android.content.Context;
import android.hardware.input.InputManager;
import android.util.SparseArray;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;


public class AndroidGamepadManager {
    // This is completely arbitrary.
    private static final float TRIGGER_PRESSED_THRESHOLD = 0.25f;
    private static final long POLL_TIMER_PERIOD = 1000; // milliseconds

    private static enum Axis {
        X(MotionEvent.AXIS_X),
        Y(MotionEvent.AXIS_Y),
        Z(MotionEvent.AXIS_Z),
        RZ(MotionEvent.AXIS_RZ);

        public final int axis;

        private Axis(int axis) {
            this.axis = axis;
        }
    }

    // A list of gamepad button mappings. Axes are determined at
    // runtime, as they vary by Android version.
    private static enum Trigger {
        Left(6),
        Right(7);

        public final int button;

        private Trigger(int button) {
            this.button = button;
        }
    }

    private static final int FIRST_DPAD_BUTTON = 12;
    // A list of axis number, gamepad button mappings for negative, positive.
    // Button mappings are added to FIRST_DPAD_BUTTON.
    private static enum DpadAxis {
        UpDown(MotionEvent.AXIS_HAT_Y, 0, 1),
        LeftRight(MotionEvent.AXIS_HAT_X, 2, 3);

        public final int axis;
        public final int negativeButton;
        public final int positiveButton;

        private DpadAxis(int axis, int negativeButton, int positiveButton) {
            this.axis = axis;
            this.negativeButton = negativeButton;
            this.positiveButton = positiveButton;
        }
    }

    private static enum Button {
        A(KeyEvent.KEYCODE_BUTTON_A),
        B(KeyEvent.KEYCODE_BUTTON_B),
        X(KeyEvent.KEYCODE_BUTTON_X),
        Y(KeyEvent.KEYCODE_BUTTON_Y),
        L1(KeyEvent.KEYCODE_BUTTON_L1),
        R1(KeyEvent.KEYCODE_BUTTON_R1),
        L2(KeyEvent.KEYCODE_BUTTON_L2),
        R2(KeyEvent.KEYCODE_BUTTON_R2),
        SELECT(KeyEvent.KEYCODE_BUTTON_SELECT),
        START(KeyEvent.KEYCODE_BUTTON_START),
        THUMBL(KeyEvent.KEYCODE_BUTTON_THUMBL),
        THUMBR(KeyEvent.KEYCODE_BUTTON_THUMBR),
        DPAD_UP(KeyEvent.KEYCODE_DPAD_UP),
        DPAD_DOWN(KeyEvent.KEYCODE_DPAD_DOWN),
        DPAD_LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
        DPAD_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT);

        public final int button;

        private Button(int button) {
            this.button = button;
        }
    }

    private static class Gamepad {
        // ID from GamepadService
        public int id;
        // Retain axis state so we can determine changes.
        public float axes[];
        public boolean dpad[];
        public int triggerAxes[];
        public float triggers[];

        public Gamepad(int serviceId, int deviceId) {
            id = serviceId;
            axes = new float[Axis.values().length];
            dpad = new boolean[4];
            triggers = new float[2];

            InputDevice device = InputDevice.getDevice(deviceId);
            if (device != null) {
                // LTRIGGER/RTRIGGER don't seem to be exposed on older
                // versions of Android.
                if (device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null && device.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null) {
                    triggerAxes = new int[]{MotionEvent.AXIS_LTRIGGER,
                                            MotionEvent.AXIS_RTRIGGER};
                } else if (device.getMotionRange(MotionEvent.AXIS_BRAKE) != null && device.getMotionRange(MotionEvent.AXIS_GAS) != null) {
                    triggerAxes = new int[]{MotionEvent.AXIS_BRAKE,
                                            MotionEvent.AXIS_GAS};
                } else {
                    triggerAxes = null;
                }
            }
        }
    }

    @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
    private static native void onGamepadChange(int id, boolean added);
    @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
    private static native void onButtonChange(int id, int button, boolean pressed, float value);
    @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
    private static native void onAxisChange(int id, boolean[] valid, float[] values);

    private static boolean sStarted;
    private static final SparseArray<Gamepad> sGamepads = new SparseArray<>();
    private static final SparseArray<List<KeyEvent>> sPendingGamepads = new SparseArray<>();
    private static InputManager.InputDeviceListener sListener;
    private static Timer sPollTimer;

    private AndroidGamepadManager() {
    }

    @WrapForJNI
    private static void start() {
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                doStart();
            }
        });
    }

    /* package */ static void doStart() {
        ThreadUtils.assertOnUiThread();
        if (!sStarted) {
            scanForGamepads();
            addDeviceListener();
            sStarted = true;
        }
    }

    @WrapForJNI
    private static void stop() {
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                doStop();
            }
        });
    }

    /* package */ static void doStop() {
        ThreadUtils.assertOnUiThread();
        if (sStarted) {
            removeDeviceListener();
            sPendingGamepads.clear();
            sGamepads.clear();
            sStarted = false;
        }
    }

    @WrapForJNI(calledFrom = "gecko")
    private static void onGamepadAdded(final int device_id, final int service_id) {
        ThreadUtils.postToUiThread(new Runnable() {
            @Override
            public void run() {
                handleGamepadAdded(device_id, service_id);
            }
        });
    }

    /* package */ static void handleGamepadAdded(int deviceId, int serviceId) {
        ThreadUtils.assertOnUiThread();
        if (!sStarted) {
            return;
        }

        final List<KeyEvent> pending = sPendingGamepads.get(deviceId);
        if (pending == null) {
            removeGamepad(deviceId);
            return;
        }

        sPendingGamepads.remove(deviceId);
        sGamepads.put(deviceId, new Gamepad(serviceId, deviceId));
        // Handle queued KeyEvents
        for (KeyEvent ev : pending) {
            handleKeyEvent(ev);
        }
    }

    private static float deadZone(MotionEvent ev, int axis) {
        if (GamepadUtils.isValueInDeadZone(ev, axis)) {
            return 0.0f;
        }
        return ev.getAxisValue(axis);
    }

    private static void mapDpadAxis(Gamepad gamepad,
                                    boolean pressed,
                                    float value,
                                    int which) {
        if (pressed != gamepad.dpad[which]) {
            gamepad.dpad[which] = pressed;
            onButtonChange(gamepad.id, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value));
        }
    }

    public static boolean handleMotionEvent(MotionEvent ev) {
        ThreadUtils.assertOnUiThread();
        if (!sStarted) {
            return false;
        }

        final Gamepad gamepad = sGamepads.get(ev.getDeviceId());
        if (gamepad == null) {
            // Not a device we care about.
            return false;
        }

        // First check the analog stick axes
        boolean[] valid = new boolean[Axis.values().length];
        float[] axes = new float[Axis.values().length];
        boolean anyValidAxes = false;
        for (Axis axis : Axis.values()) {
            float value = deadZone(ev, axis.axis);
            int i = axis.ordinal();
            if (value != gamepad.axes[i]) {
                axes[i] = value;
                gamepad.axes[i] = value;
                valid[i] = true;
                anyValidAxes = true;
            }
        }
        if (anyValidAxes) {
            // Send an axismove event.
            onAxisChange(gamepad.id, valid, axes);
        }

        // Map triggers to buttons.
        if (gamepad.triggerAxes != null) {
            for (Trigger trigger : Trigger.values()) {
                int i = trigger.ordinal();
                int axis = gamepad.triggerAxes[i];
                float value = deadZone(ev, axis);
                if (value != gamepad.triggers[i]) {
                    gamepad.triggers[i] = value;
                    boolean pressed = value > TRIGGER_PRESSED_THRESHOLD;
                    onButtonChange(gamepad.id, trigger.button, pressed, value);
                }
            }
        }
        // Map d-pad to buttons.
        for (DpadAxis dpadaxis : DpadAxis.values()) {
            float value = deadZone(ev, dpadaxis.axis);
            mapDpadAxis(gamepad, value < 0.0f, value, dpadaxis.negativeButton);
            mapDpadAxis(gamepad, value > 0.0f, value, dpadaxis.positiveButton);
        }
        return true;
    }

    public static boolean handleKeyEvent(KeyEvent ev) {
        ThreadUtils.assertOnUiThread();
        if (!sStarted) {
            return false;
        }

        int deviceId = ev.getDeviceId();
        final List<KeyEvent> pendingGamepad = sPendingGamepads.get(deviceId);
        if (pendingGamepad != null) {
            // Queue up key events for pending devices.
            pendingGamepad.add(ev);
            return true;
        }

        if (sGamepads.get(deviceId) == null) {
            InputDevice device = ev.getDevice();
            if (device != null &&
                (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
                // This is a gamepad we haven't seen yet.
                addGamepad(device);
                sPendingGamepads.get(deviceId).add(ev);
                return true;
            }
            // Not a device we care about.
            return false;
        }

        int key = -1;
        for (Button button : Button.values()) {
            if (button.button == ev.getKeyCode()) {
                key = button.ordinal();
                break;
            }
        }
        if (key == -1) {
            // Not a key we know how to handle.
            return false;
        }
        if (ev.getRepeatCount() > 0) {
            // We would handle this key, but we're not interested in
            // repeats. Eat it.
            return true;
        }

        Gamepad gamepad = sGamepads.get(deviceId);
        boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN;
        onButtonChange(gamepad.id, key, pressed, pressed ? 1.0f : 0.0f);
        return true;
    }

    private static void scanForGamepads() {
        int[] deviceIds = InputDevice.getDeviceIds();
        if (deviceIds == null) {
            return;
        }
        for (int i = 0; i < deviceIds.length; i++) {
            InputDevice device = InputDevice.getDevice(deviceIds[i]);
            if (device == null) {
                continue;
            }
            if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD) {
                continue;
            }
            addGamepad(device);
        }
    }

    private static void addGamepad(InputDevice device) {
        sPendingGamepads.put(device.getId(), new ArrayList<KeyEvent>());
        onGamepadChange(device.getId(), true);
    }

    private static void removeGamepad(int deviceId) {
        Gamepad gamepad = sGamepads.get(deviceId);
        onGamepadChange(gamepad.id, false);
        sGamepads.remove(deviceId);
    }

    private static void addDeviceListener() {
        if (Versions.preJB) {
            // Poll known gamepads to see if they've disappeared.
            sPollTimer = new Timer();
            sPollTimer.scheduleAtFixedRate(new TimerTask() {
                    @Override
                    public void run() {
                        for (int i = 0; i < sGamepads.size(); ++i) {
                            final int deviceId = sGamepads.keyAt(i);
                            if (InputDevice.getDevice(deviceId) == null) {
                                removeGamepad(deviceId);
                            }
                        }
                    }
                }, POLL_TIMER_PERIOD, POLL_TIMER_PERIOD);
            return;
        }
        sListener = new InputManager.InputDeviceListener() {
                @Override
                public void onInputDeviceAdded(int deviceId) {
                    InputDevice device = InputDevice.getDevice(deviceId);
                    if (device == null) {
                        return;
                    }
                    if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
                        addGamepad(device);
                    }
                }

                @Override
                public void onInputDeviceRemoved(int deviceId) {
                    if (sPendingGamepads.get(deviceId) != null) {
                        // Got removed before Gecko's ack reached us.
                        // gamepadAdded will deal with it.
                        sPendingGamepads.remove(deviceId);
                        return;
                    }
                    if (sGamepads.get(deviceId) != null) {
                        removeGamepad(deviceId);
                    }
                }

                @Override
                public void onInputDeviceChanged(int deviceId) {
                }
            };
        ((InputManager)GeckoAppShell.getContext().getSystemService(Context.INPUT_SERVICE)).registerInputDeviceListener(sListener, ThreadUtils.getUiHandler());
    }

    private static void removeDeviceListener() {
        if (Versions.preJB) {
            if (sPollTimer != null) {
                sPollTimer.cancel();
                sPollTimer = null;
            }
            return;
        }
        ((InputManager)GeckoAppShell.getContext().getSystemService(Context.INPUT_SERVICE)).unregisterInputDeviceListener(sListener);
        sListener = null;
    }
}
