import {
	emit,
	GAME_BEFORE_UPDATE,
	GAMEPAD_CONNECTED,
	GAMEPAD_DISCONNECTED,
	GAMEPAD_UPDATE,
	has,
	on,
} from "../system/event.ts";
import { getBindingKey, triggerKeyEvent } from "./keyboard.ts";

interface GamepadMapping {
	axes: number[];
	buttons: number[];
	analog: number[];
	normalize_fn: (value: number, axis: number, button: number) => number;
}

interface ButtonBinding {
	keyCode: number;
	value: number;
	pressed: boolean;
	threshold?: number | undefined;
}

interface GamepadBindings {
	axes: Record<string, Record<number, ButtonBinding>>;
	buttons: Record<string, ButtonBinding>;
}

// Analog deadzone
let deadzone = 0.1;

/**
 * Normalize axis values for wired Xbox 360
 * @ignore
 */
function wiredXbox360NormalizeFn(
	this: any,
	value: number,
	_axis: number,
	button: number,
): number {
	if (
		button === this.GAMEPAD.BUTTONS.L2 ||
		button === this.GAMEPAD.BUTTONS.R2
	) {
		return (value + 1) / 2;
	}
	return value;
}

/**
 * Normalize axis values for OUYA
 * @ignore
 */
function ouyaNormalizeFn(
	this: any,
	value: number,
	_axis: number,
	button: number,
): number {
	if (value > 0) {
		if (button === this.GAMEPAD.BUTTONS.L2) {
			// L2 is wonky; seems like the deadzone is around 20000
			// (That's over 15% of the total range!)
			value = Math.max(0, value - 20000) / 111070;
		} else {
			// Normalize [1..65536] => [0.0..0.5]
			value = (value - 1) / 131070;
		}
	} else {
		// Normalize [-65536..-1] => [0.5..1.0]
		value = (65536 + value) / 131070 + 0.5;
	}

	return value;
}

// Match vendor and product codes for Firefox
const vendorProductRE = /^([0-9a-f]{1,4})-([0-9a-f]{1,4})-/i;

// Match leading zeros
const leadingZeroRE = /^0+/;

/**
 * Firefox reports different ids for gamepads depending on the platform:
 * - Windows: vendor and product codes contain leading zeroes
 * - Mac: vendor and product codes are sparse (no leading zeroes)
 *
 * This function normalizes the id to support both formats
 * @ignore
 */
function addMapping(id: string, mapping: Partial<GamepadMapping>): void {
	const expanded_id = id.replace(
		vendorProductRE,
		(_: string, a: string, b: string) => {
			return `${"000".slice(a.length - 1) + a}-${"000".slice(b.length - 1)}${b}-`;
		},
	);
	const sparse_id = id.replace(
		vendorProductRE,
		(_: string, a: string, b: string) => {
			return `${a.replace(leadingZeroRE, "")}-${b.replace(leadingZeroRE, "")}-`;
		},
	);

	// Normalize optional parameters
	mapping.analog =
		mapping.analog ||
		mapping.buttons!.map(() => {
			return -1;
		});
	mapping.normalize_fn =
		mapping.normalize_fn ||
		function (value: number) {
			return value;
		};

	remap.set(expanded_id, mapping as GamepadMapping);
	remap.set(sparse_id, mapping as GamepadMapping);
}

// binding list
const bindings: Record<string, GamepadBindings | undefined> = {};

// mapping list
const remap: Map<string, GamepadMapping> = new Map();

// Default gamepad mappings
(
	[
		// Firefox mappings
		[
			"45e-28e-Xbox 360 Wired Controller",
			{
				axes: [0, 1, 3, 4],
				buttons: [11, 12, 13, 14, 8, 9, -1, -1, 5, 4, 6, 7, 0, 1, 2, 3, 10],
				analog: [
					-1, -1, -1, -1, -1, -1, 2, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1,
				],
				normalize_fn: wiredXbox360NormalizeFn,
			},
		],
		[
			"54c-268-PLAYSTATION(R)3 Controller",
			{
				axes: [0, 1, 2, 3],
				buttons: [14, 13, 15, 12, 10, 11, 8, 9, 0, 3, 1, 2, 4, 6, 7, 5, 16],
			},
		],
		[
			"54c-5c4-Wireless Controller", // PS4 Controller
			{
				axes: [0, 1, 2, 3],
				buttons: [1, 0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16, 17, 12, 13],
			},
		],
		[
			"2836-1-OUYA Game Controller",
			{
				axes: [0, 3, 7, 9],
				buttons: [3, 6, 4, 5, 7, 8, 15, 16, -1, -1, 9, 10, 11, 12, 13, 14, -1],
				analog: [
					-1, -1, -1, -1, -1, -1, 5, 11, -1, -1, -1, -1, -1, -1, -1, -1, -1,
				],
				normalize_fn: ouyaNormalizeFn,
			},
		],

		// Chrome mappings
		[
			"OUYA Game Controller (Vendor: 2836 Product: 0001)",
			{
				axes: [0, 1, 3, 4],
				buttons: [0, 3, 1, 2, 4, 5, 12, 13, -1, -1, 6, 7, 8, 9, 10, 11, -1],
				analog: [
					-1, -1, -1, -1, -1, -1, 2, 5, -1, -1, -1, -1, -1, -1, -1, -1, -1,
				],
				normalize_fn: ouyaNormalizeFn,
			},
		],
	] as [string, Partial<GamepadMapping>][]
).forEach((value) => {
	addMapping(value[0], value[1]);
});

/**
 * Update gamepad status
 * @ignore
 */
const updateGamepads = function (): void {
	const gamepads = navigator.getGamepads();

	// Trigger button bindings
	Object.keys(bindings).forEach((index) => {
		const gamepad = gamepads[index as any];
		if (!gamepad) {
			return;
		}

		let mapping: GamepadMapping | null = null;
		if (gamepad.mapping !== "standard") {
			mapping = remap.get(gamepad.id) || null;
		}

		const binding = bindings[index];
		if (!binding) {
			return;
		}

		// Iterate all buttons that have active bindings
		Object.keys(binding.buttons).forEach((button) => {
			const last = binding.buttons[button];
			let mapped_button: any = button;
			let mapped_axis = -1;

			// Remap buttons if necessary
			if (mapping) {
				mapped_button = mapping.buttons[button as any];
				mapped_axis = mapping.analog[button as any];
				if (mapped_button < 0 && mapped_axis < 0) {
					// Button is not mapped
					return;
				}
			}

			// Get mapped button
			let current: { value: number; pressed: boolean } = gamepad.buttons[
				mapped_button
			] || { value: 0, pressed: false };

			// Remap an axis to an analog button
			if (mapping) {
				if (mapped_axis >= 0) {
					const value = mapping.normalize_fn(
						gamepad.axes[mapped_axis],
						-1,
						+button,
					);

					// Create a new object, because GamepadButton is read-only
					current = {
						value: value,
						pressed: current.pressed || Math.abs(value) >= deadzone,
					};
				}
			}

			emit(GAMEPAD_UPDATE, index, "buttons", +button, current);

			// Edge detection
			if (!last.pressed && current.pressed) {
				triggerKeyEvent(last.keyCode, true, Number(mapped_button) + 256);
			} else if (last.pressed && !current.pressed) {
				triggerKeyEvent(last.keyCode, false, Number(mapped_button) + 256);
			}

			// Update last button state
			last.value = current.value;
			last.pressed = current.pressed;
		});

		// Iterate all axes that have active bindings
		Object.keys(binding.axes).forEach((axis) => {
			const last = binding.axes[axis];
			let mapped_axis: any = axis;

			// Remap buttons if necessary
			if (mapping) {
				mapped_axis = mapping.axes[axis as any];
				if (mapped_axis < 0) {
					// axe is not mapped
					return;
				}
			}

			// retrieve the current value and normalize if necessary
			let value = gamepad.axes[mapped_axis];
			if (typeof value === "undefined") {
				return;
			}
			if (mapping) {
				value = mapping.normalize_fn(value, +axis, -1);
			}
			// normalize value into a [-1, 1] range value (treat 0 as positive)
			let range: number = Math.sign(value) || 1;
			if (last[range].keyCode === 0) {
				return;
			}
			const pressed =
				Math.abs(value) >= deadzone + Math.abs(last[range].threshold || 0);

			emit(GAMEPAD_UPDATE, index, "axes", +axis, value as any);

			// Edge detection
			if (!last[range].pressed && pressed) {
				// Release the opposite direction, if necessary
				if (last[-range].pressed) {
					triggerKeyEvent(
						last[-range].keyCode,
						false,
						Number(mapped_axis) + 256,
					);
					last[-range].value = 0;
					last[-range].pressed = false;
				}

				triggerKeyEvent(last[range].keyCode, true, Number(mapped_axis) + 256);
			} else if ((last[range].pressed || last[-range].pressed) && !pressed) {
				range = last[range].pressed ? range : -range;
				triggerKeyEvent(last[range].keyCode, false, Number(mapped_axis) + 256);
			}

			// Update last axis state
			last[range].value = value;
			last[range].pressed = pressed;
		});
	});
};

// gamepad connected callback
if (
	globalThis.navigator &&
	typeof globalThis.navigator.getGamepads === "function"
) {
	globalThis.addEventListener(
		"gamepadconnected",
		(e: GamepadEvent) => {
			emit(GAMEPAD_CONNECTED, e.gamepad);
		},
		false,
	);

	/*
	 * gamepad disconnected callback
	 */
	globalThis.addEventListener(
		"gamepaddisconnected",
		(e: GamepadEvent) => {
			emit(GAMEPAD_DISCONNECTED, e.gamepad);
		},
		false,
	);
}

/*
 * PUBLIC STUFF
 */

/**
 * Namespace for standard gamepad mapping constants
 */
export const GAMEPAD = {
	/**
	 * Standard gamepad mapping information for axes<br>
	 * <ul>
	 *   <li>Left control stick: <code>LX</code> (horizontal), <code>LY</code> (vertical)</li>
	 *   <li>Right control stick: <code>RX</code> (horizontal), <code>RY</code> (vertical)</li>
	 *   <li>Extras: <code>EXTRA_1</code>, <code>EXTRA_2</code>, <code>EXTRA_3</code>, <code>EXTRA_4</code></li>
	 * </ul>
	 * @see {@link https://w3c.github.io/gamepad/#remapping}
	 */
	AXES: {
		LX: 0,
		LY: 1,
		RX: 2,
		RY: 3,
		EXTRA_1: 4,
		EXTRA_2: 5,
		EXTRA_3: 6,
		EXTRA_4: 7,
	},

	/**
	 * Standard gamepad mapping information for buttons<br>
	 * <ul>
	 *   <li>Face buttons: <code>FACE_1</code>, <code>FACE_2</code>, <code>FACE_3</code>, <code>FACE_4</code></li>
	 *   <li>D-Pad: <code>UP</code>, <code>DOWN</code>, <code>LEFT</code>, <code>RIGHT</code></li>
	 *   <li>Shoulder buttons: <code>L1</code>, <code>L2</code>, <code>R1</code>, <code>R2</code></li>
	 *   <li>Analog stick (clicks): <code>L3</code>, <code>R3</code></li>
	 *   <li>Navigation: <code>SELECT</code> (<code>BACK</code>), <code>START</code> (<code>FORWARD</code>), <code>HOME</code></li>
	 *   <li>Extras: <code>EXTRA_1</code>, <code>EXTRA_2</code>, <code>EXTRA_3</code>, <code>EXTRA_4</code></li>
	 * </ul>
	 * @see {@link https://w3c.github.io/gamepad/#remapping}
	 */
	BUTTONS: {
		FACE_1: 0,
		FACE_2: 1,
		FACE_3: 2,
		FACE_4: 3,
		L1: 4,
		R1: 5,
		L2: 6,
		R2: 7,
		SELECT: 8,
		BACK: 8,
		START: 9,
		FORWARD: 9,
		L3: 10,
		R3: 11,
		UP: 12,
		DOWN: 13,
		LEFT: 14,
		RIGHT: 15,
		HOME: 16,
		EXTRA_1: 17,
		EXTRA_2: 18,
		EXTRA_3: 19,
		EXTRA_4: 20,
	},
} as const;

/**
 * Associate a gamepad event to a keycode
 * @param index - Gamepad index
 * @param button - Button/Axis definition
 * @param button.type - "buttons" or "axes"
 * @param button.code - button or axis code id
 * @param button.threshold - value indicating when the axis should trigger the keycode
 * @param keyCode - (See {@link input.KEY})
 * @example
 * // enable the keyboard
 * me.input.bindKey(me.input.KEY.X, "shoot");
 * ...
 * // map the lower face button on the first gamepad to the X key
 * me.input.bindGamepad(0, {type:"buttons", code: me.input.GAMEPAD.BUTTONS.FACE_1}, me.input.KEY.X);
 * // map the left axis value on the first gamepad to the LEFT key
 * me.input.bindGamepad(0, {type:"axes", code: me.input.GAMEPAD.AXES.LX, threshold: -0.5}, me.input.KEY.LEFT);
 * @category Input
 */
export function bindGamepad(
	index: number,
	button: { type: "buttons" | "axes"; code: number; threshold?: number },
	keyCode: number,
): void {
	// Throw an exception if no action is defined for the specified keycode
	if (!getBindingKey(keyCode)) {
		throw new Error(`no action defined for keycode ${keyCode}`);
	}

	// register to the the update event if not yet done and supported by the browser
	// if not supported, the function will fail silently (-> update loop won't be called)
	if (
		!has(GAME_BEFORE_UPDATE, updateGamepads) &&
		typeof navigator.getGamepads === "function"
	) {
		on(GAME_BEFORE_UPDATE, updateGamepads);
	}

	// Allocate bindings if not defined
	if (!bindings[index]) {
		bindings[index] = {
			axes: {},
			buttons: {},
		};
	}

	const mapping: ButtonBinding = {
		keyCode: keyCode,
		value: 0,
		pressed: false,
		threshold: button.threshold, // can be undefined
	};
	const binding = bindings[index][button.type] as any;

	// Map the gamepad button or axis to the keycode
	if (button.type === "buttons") {
		// buttons are defined by a `gamePadButton` object
		binding[button.code] = mapping;
	} else if (button.type === "axes") {
		// normalize threshold into a value that can represent both side of the axis
		const range = Math.sign(button.threshold!) || 1;
		// axes are defined using two objects; one for negative and one for positive
		if (!binding[button.code]) {
			binding[button.code] = {};
		}
		const axes = binding[button.code];
		axes[range] = mapping;

		// Ensure the opposite axis exists
		if (!axes[-range]) {
			axes[-range] = {
				keyCode: 0,
				value: 0,
				pressed: false,
				threshold: -range,
			};
		}
	}
}

/**
 * unbind the defined keycode
 * @param index - Gamepad index
 * @param button - (See {@link input.GAMEPAD})
 * @example
 * me.input.unbindGamepad(0, me.input.GAMEPAD.BUTTONS.FACE_1);
 * @category Input
 */
export function unbindGamepad(index: number, button: number): void {
	if (!bindings[index]) {
		throw new Error(`no bindings for gamepad ${index}`);
	}
	bindings[index].buttons[button] = {} as ButtonBinding;
}

/**
 * Set deadzone for analog gamepad inputs<br>
 * The default deadzone is 0.1 (10%) Analog values less than this will be ignored
 * @param value - Deadzone value
 * @category Input
 */
export function setGamepadDeadzone(value: number): void {
	deadzone = value;
}

/**
 * specify a custom mapping for a specific gamepad id<br>
 * see below for the default mapping : <br>
 * <center><img src="images/gamepad_diagram.png"/></center><br>
 * @param id - Gamepad id string
 * @param mapping - A hash table
 * @example
 * // A weird controller that has its axis mappings reversed
 * me.input.setGamepadMapping("Generic USB Controller", {
 *   "axes" : [ 3, 2, 1, 0 ],
 *   "buttons" : [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ]
 * });
 *
 * // Mapping extra axes to analog buttons
 * me.input.setGamepadMapping("Generic Analog Controller", {
 *   "axes" : [ 0, 1, 2, 3 ],
 *   "buttons" : [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ],
 *
 *   // Raw axis 4 is mapped to GAMEPAD.BUTTONS.FACE_1
 *   // Raw axis 5 is mapped to GAMEPAD.BUTTONS.FACE_2
 *   // etc...
 *   // Also maps left and right triggers
 *   "analog" : [ 4, 5, 6, 7, -1, -1, 8, 9, -1, -1, -1, -1, -1, -1, -1, -1, -1 ],
 *
 *   // Normalize the value of button L2: [-1.0..1.0] => [0.0..1.0]
 *   "normalize_fn" : function (value, axis, button) {
 *     return ((button === me.input.GAMEPAD.BUTTONS.L2) ? ((value + 1) / 2) : value) || 0;
 *   }
 * });
 */
export const setGamepadMapping: (
	id: string,
	mapping: Partial<GamepadMapping>,
) => void = addMapping;
