import {
  findKey,
  isEqual,
  map,
  assignIn,
  forEach,
  flow,
  flatten,
  uniq,
  uniqBy,
  toString,
} from 'lodash/fp';

import { nameIsValid } from '../common/utils';
import { mockGamepad, getDefaultButtons, getDefaultSticks, updateListenOptions } from './baseUtils';
import {
  stopRumble,
  addRumble,
  applyRumble,
  getCurrentEffect,
  updateChannels,
  MAX_DURATION,
} from './rumble';
import {
  ListenOptions,
  RawGamepad,
  Effect,
  BaseParams,
  CustomGamepad,
  StrictEffect,
  Button,
  Stick,
} from '../types';

export type BaseModule = ReturnType<typeof createModule>;

interface BaseState {
  threshold: number;
  clampThreshold: boolean;
  pad: CustomGamepad;
  prevPad: CustomGamepad;
  prevRumble: StrictEffect;
  lastRumbleUpdate: number;
  lastUpdate: number;

  buttons: Record<string, Button>;
  sticks: Record<string, Stick>;
}

export default function createModule(params: BaseParams = {}) {
  let listenOptions: ListenOptions | null = null;
  let gamepadId = params.padId ? params.padId : null;
  let connected = !!params.padId;

  const state: BaseState = {
    threshold: params.threshold || 0.2,
    clampThreshold: params.clampThreshold !== false,
    pad: mockGamepad,
    prevPad: mockGamepad,
    prevRumble: {
      duration: 0,
      weakMagnitude: 0,
      strongMagnitude: 0,
    },
    lastRumbleUpdate: Date.now(),
    lastUpdate: Date.now(),

    buttons: getDefaultButtons(),
    sticks: getDefaultSticks(),
  };

  const module = {
    getPadId: () => gamepadId,

    isConnected: () => connected,

    disconnect: () => {
      connected = false;
    },

    connect: (padId?: string) => {
      connected = true;
      if (padId) {
        gamepadId = padId;
      }
    },

    getConfig: () =>
      JSON.stringify({
        threshold: state.threshold,
        clampThreshold: state.clampThreshold,
        buttons: state.buttons,
        sticks: state.sticks,
      }),

    setConfig: (serializedString: string) => assignIn(state, JSON.parse(serializedString)),

    getButtonIndexes: (...inputNames: string[]) =>
      flow(
        map((inputName: string) => state.buttons[inputName]),
        flatten,
        uniq,
      )(inputNames),

    getStickIndexes: (...inputNames: string[]) =>
      flow(
        map((inputName: string) => state.sticks[inputName].indexes),
        flatten,
        uniqBy(toString),
      )(inputNames),

    setButton: (inputName: string, indexes: number[]) => {
      if (!nameIsValid(inputName)) {
        throw new Error(`On setButton('${inputName}'): argument contains invalid characters`);
      }
      state.buttons[inputName] = indexes;
    },

    setStick: (inputName: string, indexes: number[][], inverts?: boolean[]) => {
      if (!nameIsValid(inputName)) {
        throw new Error(`On setStick('${inputName}'): inputName contains invalid characters`);
      }

      if (indexes.length === 0) {
        throw new Error(`On setStick('${inputName}', indexes): argument indexes is an empty array`);
      }

      state.sticks[inputName] = {
        indexes,
        inverts: inverts || map(() => false, indexes[0]),
      };
    },

    invertSticks: (inverts: boolean[], ...inputNames: string[]) => {
      forEach((inputName) => {
        const stick = state.sticks[inputName];
        if (stick.inverts.length === inverts.length) {
          stick.inverts = inverts;
        } else {
          throw new Error(
            `On invertSticks(inverts, [..., ${inputName}, ...]): given argument inverts' length does not match '${inputName}' axis' length`,
          );
        }
      }, inputNames);
    },

    swapButtons: (btn1: string, btn2: string) => {
      const { buttons } = state;
      [buttons[btn1], buttons[btn2]] = [buttons[btn2], buttons[btn1]];
    },

    swapSticks: (stick1: string, stick2: string, includeInverts = false) => {
      const { sticks } = state;
      if (includeInverts) {
        [sticks[stick1], sticks[stick2]] = [sticks[stick2], sticks[stick1]];
      } else {
        [sticks[stick1].indexes, sticks[stick2].indexes] = [
          sticks[stick2].indexes,
          sticks[stick1].indexes,
        ];
      }
    },

    update: (gamepad: RawGamepad) => {
      state.prevPad = state.pad;
      state.pad = {
        axes: gamepad.axes as number[],
        buttons: map((a) => a.value, gamepad.buttons),
        rawPad: gamepad,
      };

      if (listenOptions) {
        listenOptions = updateListenOptions(listenOptions, state.pad, state.threshold);
      }

      // Update rumble state

      if (module.isRumbleSupported()) {
        const now = Date.now();
        const currentRumble = getCurrentEffect(gamepad.id);
        updateChannels(gamepad.id, now - state.lastUpdate);

        if (
          state.prevRumble.weakMagnitude !== currentRumble.weakMagnitude ||
          state.prevRumble.strongMagnitude !== currentRumble.strongMagnitude ||
          now - state.lastRumbleUpdate >= MAX_DURATION / 2
        ) {
          applyRumble(gamepad, currentRumble);
          state.prevRumble = currentRumble;
          state.lastRumbleUpdate = now;
        }

        state.lastUpdate = now;
      }
    },

    cancelListen: () => {
      listenOptions = null;
    },

    listenButton: (
      callback: (indexes: number[]) => void,
      quantity = 1,
      {
        waitFor = [1, 'polls'],
        consecutive = false,
        allowOffset = true,
      }: { waitFor?: [number, 'polls' | 'ms']; consecutive?: boolean; allowOffset?: boolean } = {},
    ) => {
      listenOptions = {
        callback: callback as (indexes: number[] | number[][]) => void,
        quantity,
        type: 'buttons',
        currentValue: 0,
        useTimeStamp: waitFor[1] === 'ms',
        targetValue: waitFor[0],
        consecutive,
        allowOffset,
      };
    },

    listenAxis: (
      callback: (indexes: number[][]) => void,
      quantity = 2,
      {
        waitFor = [100, 'ms'],
        consecutive = true,
        allowOffset = true,
      }: { waitFor?: [number, 'polls' | 'ms']; consecutive?: boolean; allowOffset?: boolean } = {},
    ) => {
      listenOptions = {
        callback: callback as (indexes: number[] | number[][]) => void,
        quantity,
        type: 'axes',
        currentValue: 0,
        useTimeStamp: waitFor[1] === 'ms',
        targetValue: waitFor[0],
        consecutive,
        allowOffset,
      };
    },

    buttonBindOnPress: (
      inputName: string,
      callback: (buttonName?: string) => void,
      allowDuplication = false,
    ) => {
      if (!nameIsValid(inputName)) {
        throw new Error(
          `On buttonBindOnPress('${inputName}'): inputName contains invalid characters`,
        );
      }

      module.listenButton((indexes: number[]) => {
        const resultName = findKey((value) => value[0] === indexes[0], state.buttons);

        if (!allowDuplication && resultName && state.buttons[inputName]) {
          module.swapButtons(inputName, resultName);
        } else {
          module.setButton(inputName, indexes);
        }

        callback(resultName);
      });
    },

    stickBindOnPress: (
      inputName: string,
      callback: (stickName?: string) => void,
      allowDuplication = false,
    ) => {
      if (!nameIsValid(inputName)) {
        throw new Error(
          `On stickBindOnPress('${inputName}'): inputName contains invalid characters`,
        );
      }

      module.listenAxis((indexesResult: number[][]) => {
        const resultName = findKey(({ indexes }) => isEqual(indexes, indexesResult), state.sticks);

        if (!allowDuplication && resultName && state.sticks[inputName]) {
          module.swapSticks(inputName, resultName);
        } else {
          module.setStick(inputName, indexesResult);
        }

        callback(resultName);
      });
    },

    isRumbleSupported: (rawPad?: RawGamepad) => {
      const padToTest = rawPad || state.pad.rawPad;
      if (padToTest) {
        return !!padToTest.vibrationActuator && !!padToTest.vibrationActuator.playEffect;
      } else {
        return null;
      }
    },

    stopRumble: (channelName?: string) => {
      if (state.pad.rawPad) {
        stopRumble(state.pad.rawPad.id, channelName);
      }
    },

    addRumble: (effect: Effect | Effect[], channelName?: string) => {
      if (state.pad.rawPad) {
        addRumble(state.pad.rawPad.id, effect, channelName);
      }
    },

    destroy: () => {
      module.disconnect();
      state.pad = mockGamepad;
      state.prevPad = mockGamepad;
    },
  };

  return { module, state };
}
