/*
 * Copyright (C) 2020  Yomichan Authors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

/* global
 * DocumentUtil
 */

class KeyboardMouseInputField extends EventDispatcher {
    constructor(inputNode, mouseButton, os, isPointerTypeSupported=null) {
        super();
        this._inputNode = inputNode;
        this._mouseButton = mouseButton;
        this._isPointerTypeSupported = isPointerTypeSupported;
        this._keySeparator = ' + ';
        this._inputNameMap = new Map(DocumentUtil.getModifierKeys(os));
        this._keyPriorities = new Map([
            ['meta', -4],
            ['ctrl', -3],
            ['alt', -2],
            ['shift', -1]
        ]);
        this._mouseInputNamePattern = /^mouse(\d+)$/;
        this._eventListeners = new EventListenerCollection();
        this._value = '';
        this._type = null;
        this._penPointerIds = new Set();
    }

    get value() {
        return this._value;
    }

    prepare(value, type) {
        this.cleanup();

        this._value = value;
        const modifiers = this._splitValue(value);
        const {displayValue} = this._getInputStrings(modifiers);
        const events = [
            [this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false]
        ];
        if (type === 'modifierInputs' && this._mouseButton !== null) {
            events.push(
                [this._mouseButton, 'mousedown', this._onMouseButtonMouseDown.bind(this), false],
                [this._mouseButton, 'pointerdown', this._onMouseButtonPointerDown.bind(this), false],
                [this._mouseButton, 'pointerover', this._onMouseButtonPointerOver.bind(this), false],
                [this._mouseButton, 'pointerout', this._onMouseButtonPointerOut.bind(this), false],
                [this._mouseButton, 'pointercancel', this._onMouseButtonPointerCancel.bind(this), false],
                [this._mouseButton, 'mouseup', this._onMouseButtonMouseUp.bind(this), false],
                [this._mouseButton, 'contextmenu', this._onMouseButtonContextMenu.bind(this), false]
            );
        }
        this._inputNode.value = displayValue;
        for (const args of events) {
            this._eventListeners.addEventListener(...args);
        }
    }

    cleanup() {
        this._eventListeners.removeAllEventListeners();
        this._value = '';
        this._type = null;
        this._penPointerIds.clear();
    }

    clearInputs() {
        this._updateInputs([]);
    }

    // Private

    _splitValue(value) {
        return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0);
    }

    _sortInputs(inputs) {
        const pattern = this._mouseInputNamePattern;
        const keyPriorities = this._keyPriorities;
        const inputInfos = inputs.map((value, index) => {
            const match = pattern.exec(value);
            if (match !== null) {
                return [value, 1, Number.parseInt(match[1], 10), index];
            } else {
                let priority = keyPriorities.get(value);
                if (typeof priority === 'undefined') { priority = 0; }
                return [value, 0, priority, index];
            }
        });
        inputInfos.sort((a, b) => {
            let i = a[1] - b[1];
            if (i !== 0) { return i; }

            i = a[2] - b[2];
            if (i !== 0) { return i; }

            i = a[0].localeCompare(b[0], 'en-US'); // Ensure an invariant culture
            if (i !== 0) { return i; }

            i = a[3] - b[3];
            return i;
        });
        return inputInfos.map(([value]) => value);
    }

    _getInputStrings(inputs) {
        let value = '';
        let displayValue = '';
        let first = true;
        for (const input of inputs) {
            const {name} = this._getInputName(input);
            if (first) {
                first = false;
            } else {
                value += ', ';
                displayValue += this._keySeparator;
            }
            value += input;
            displayValue += name;
        }
        return {value, displayValue};
    }

    _getInputName(value) {
        const pattern = this._mouseInputNamePattern;
        const match = pattern.exec(value);
        if (match !== null) {
            return {name: `Mouse ${match[1]}`, type: 'mouse'};
        }

        let name = this._inputNameMap.get(value);
        if (typeof name === 'undefined') { name = value; }
        return {name, type: 'key'};
    }

    _getModifierKeys(e) {
        const modifiers = new Set(DocumentUtil.getActiveModifiers(e));
        // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey
        // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta
        // It works with mouse events on some platforms, so try to determine if metaKey is pressed.
        // This is a hack and only works when both Shift and Alt are not pressed.
        if (
            !modifiers.has('meta') &&
            DocumentUtil.getKeyFromEvent(e) === 'Meta' &&
            !(
                modifiers.size === 2 &&
                modifiers.has('shift') &&
                modifiers.has('alt')
            )
        ) {
            modifiers.add('meta');
        }
        return modifiers;
    }

    _onModifierKeyDown(e) {
        e.preventDefault();

        const key = DocumentUtil.getKeyFromEvent(e);
        switch (key) {
            case 'Escape':
            case 'Backspace':
                this.clearInputs();
                break;
            default:
                this._addInputs(this._getModifierKeys(e));
                break;
        }
    }

    _onMouseButtonMouseDown(e) {
        e.preventDefault();
        this._addInputs(DocumentUtil.getActiveButtons(e));
    }

    _onMouseButtonPointerDown(e) {
        if (!e.isPrimary) { return; }

        let {pointerType, pointerId} = e;
        // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events.
        if (this._penPointerIds.has(pointerId)) { pointerType = 'pen'; }

        if (
            typeof this._isPointerTypeSupported !== 'function' ||
            !this._isPointerTypeSupported(pointerType)
        ) {
            return;
        }
        e.preventDefault();
        this._addInputs(DocumentUtil.getActiveButtons(e));
    }

    _onMouseButtonPointerOver(e) {
        const {pointerType, pointerId} = e;
        if (pointerType === 'pen') {
            this._penPointerIds.add(pointerId);
        }
    }

    _onMouseButtonPointerOut(e) {
        const {pointerId} = e;
        this._penPointerIds.delete(pointerId);
    }

    _onMouseButtonPointerCancel(e) {
        this._onMouseButtonPointerOut(e);
    }

    _onMouseButtonMouseUp(e) {
        e.preventDefault();
    }

    _onMouseButtonContextMenu(e) {
        e.preventDefault();
    }

    _addInputs(newInputs) {
        const inputs = new Set(this._splitValue(this._value));
        for (const input of newInputs) {
            inputs.add(input);
        }
        this._updateInputs([...inputs]);
    }

    _updateInputs(inputs) {
        inputs = this._sortInputs(inputs);

        const node = this._inputNode;
        const {value, displayValue} = this._getInputStrings(inputs);
        node.value = displayValue;
        if (this._value === value) { return; }
        this._value = value;
        this.trigger('change', {value, displayValue});
    }
}