/*
 * Copyright (C) 2023-2024  Yomitan Authors
 * Copyright (C) 2020-2022  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/>.
 */

import {EventDispatcher} from '../../core/event-dispatcher.js';
import {EventListenerCollection} from '../../core/event-listener-collection.js';
import {DocumentUtil} from '../../dom/document-util.js';
import {HotkeyUtil} from '../../input/hotkey-util.js';

/**
 * @augments EventDispatcher<import('keyboard-mouse-input-field').Events>
 */
export class KeyboardMouseInputField extends EventDispatcher {
    /**
     * @param {HTMLInputElement} inputNode
     * @param {?HTMLButtonElement} mouseButton
     * @param {?import('environment').OperatingSystem} os
     * @param {?(pointerType: string) => boolean} [isPointerTypeSupported]
     */
    constructor(inputNode, mouseButton, os, isPointerTypeSupported = null) {
        super();
        /** @type {HTMLInputElement} */
        this._inputNode = inputNode;
        /** @type {?HTMLButtonElement} */
        this._mouseButton = mouseButton;
        /** @type {?(pointerType: string) => boolean} */
        this._isPointerTypeSupported = isPointerTypeSupported;
        /** @type {HotkeyUtil} */
        this._hotkeyUtil = new HotkeyUtil(os);
        /** @type {EventListenerCollection} */
        this._eventListeners = new EventListenerCollection();
        /** @type {?string} */
        this._key = null;
        /** @type {import('input').Modifier[]} */
        this._modifiers = [];
        /** @type {Set<number>} */
        this._penPointerIds = new Set();
        /** @type {boolean} */
        this._mouseModifiersSupported = false;
        /** @type {boolean} */
        this._keySupported = false;
    }

    /** @type {import('input').Modifier[]} */
    get modifiers() {
        return this._modifiers;
    }

    /**
     * @param {?string} key
     * @param {import('input').Modifier[]} modifiers
     * @param {boolean} [mouseModifiersSupported]
     * @param {boolean} [keySupported]
     */
    prepare(key, modifiers, mouseModifiersSupported = false, keySupported = false) {
        this.cleanup();

        this._mouseModifiersSupported = mouseModifiersSupported;
        this._keySupported = keySupported;
        this.setInput(key, modifiers);
        /** @type {import('event-listener-collection').AddEventListenerArgs[]} */
        const events = [
            [this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false],
            [this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false]
        ];
        if (mouseModifiersSupported && 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]
            );
        }
        for (const args of events) {
            this._eventListeners.addEventListener(...args);
        }
    }

    /**
     * @param {?string} key
     * @param {import('input').Modifier[]} modifiers
     */
    setInput(key, modifiers) {
        this._key = key;
        this._modifiers = this._sortModifiers(modifiers);
        this._updateDisplayString();
    }

    /** */
    cleanup() {
        this._eventListeners.removeAllEventListeners();
        this._modifiers = [];
        this._key = null;
        this._mouseModifiersSupported = false;
        this._keySupported = false;
        this._penPointerIds.clear();
    }

    /** */
    clearInputs() {
        this._updateModifiers([], null);
    }

    // Private

    /**
     * @param {import('input').Modifier[]} modifiers
     * @returns {import('input').Modifier[]}
     */
    _sortModifiers(modifiers) {
        return this._hotkeyUtil.sortModifiers(modifiers);
    }

    /** */
    _updateDisplayString() {
        const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers);
        this._inputNode.value = displayValue;
    }

    /**
     * @param {KeyboardEvent} e
     * @returns {Set<import('input').ModifierKey>}
     */
    _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') &&
            e.key === 'Meta' &&
            !(
                modifiers.size === 2 &&
                modifiers.has('shift') &&
                modifiers.has('alt')
            )
        ) {
            modifiers.add('meta');
        }
        return modifiers;
    }

    /**
     * @param {string|undefined} keyName
     * @returns {boolean}
     */
    _isModifierKey(keyName) {
        switch (keyName) {
            case 'AltLeft':
            case 'AltRight':
            case 'ControlLeft':
            case 'ControlRight':
            case 'MetaLeft':
            case 'MetaRight':
            case 'ShiftLeft':
            case 'ShiftRight':
            case 'OSLeft':
            case 'OSRight':
                return true;
            default:
                return false;
        }
    }

    /**
     * @param {KeyboardEvent} e
     */
    _onModifierKeyDown(e) {
        e.preventDefault();

        /** @type {string|undefined} */
        let key = e.code;
        if (key === 'Unidentified' || key === '') { key = void 0; }
        if (this._keySupported) {
            this._updateModifiers([...this._getModifierKeys(e)], this._isModifierKey(key) ? void 0 : key);
        } else {
            switch (key) {
                case 'Escape':
                case 'Backspace':
                    this.clearInputs();
                    break;
                default:
                    this._addModifiers(this._getModifierKeys(e));
                    break;
            }
        }
    }

    /**
     * @param {KeyboardEvent} e
     */
    _onModifierKeyUp(e) {
        e.preventDefault();
    }

    /**
     * @param {MouseEvent} e
     */
    _onMouseButtonMouseDown(e) {
        e.preventDefault();
        this._addModifiers(DocumentUtil.getActiveButtons(e));
    }

    /**
     * @param {PointerEvent} 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._addModifiers(DocumentUtil.getActiveButtons(e));
    }

    /**
     * @param {PointerEvent} e
     */
    _onMouseButtonPointerOver(e) {
        const {pointerType, pointerId} = e;
        if (pointerType === 'pen') {
            this._penPointerIds.add(pointerId);
        }
    }

    /**
     * @param {PointerEvent} e
     */
    _onMouseButtonPointerOut(e) {
        const {pointerId} = e;
        this._penPointerIds.delete(pointerId);
    }

    /**
     * @param {PointerEvent} e
     */
    _onMouseButtonPointerCancel(e) {
        this._onMouseButtonPointerOut(e);
    }

    /**
     * @param {MouseEvent} e
     */
    _onMouseButtonMouseUp(e) {
        e.preventDefault();
    }

    /**
     * @param {MouseEvent} e
     */
    _onMouseButtonContextMenu(e) {
        e.preventDefault();
    }

    /**
     * @param {Iterable<import('input').Modifier>} newModifiers
     * @param {?string} [newKey]
     */
    _addModifiers(newModifiers, newKey) {
        const modifiers = new Set(this._modifiers);
        for (const modifier of newModifiers) {
            modifiers.add(modifier);
        }
        this._updateModifiers([...modifiers], newKey);
    }

    /**
     * @param {import('input').Modifier[]} modifiers
     * @param {?string} [newKey]
     */
    _updateModifiers(modifiers, newKey) {
        modifiers = this._sortModifiers(modifiers);

        let changed = false;
        if (typeof newKey !== 'undefined' && this._key !== newKey) {
            this._key = newKey;
            changed = true;
        }
        if (!this._areArraysEqual(this._modifiers, modifiers)) {
            this._modifiers = modifiers;
            changed = true;
        }

        this._updateDisplayString();
        if (changed) {
            this.trigger('change', {modifiers: this._modifiers, key: this._key});
        }
    }

    /**
     * @template [T=unknown]
     * @param {T[]} array1
     * @param {T[]} array2
     * @returns {boolean}
     */
    _areArraysEqual(array1, array2) {
        const length = array1.length;
        if (length !== array2.length) { return false; }

        for (let i = 0; i < length; ++i) {
            if (array1[i] !== array2[i]) { return false; }
        }

        return true;
    }
}