/*
 * Copyright (C) 2023  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, EventListenerCollection, invokeMessageHandler, log} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';
import {yomitan} from '../yomitan.js';

/**
 * @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEventType>
 */
class CrossFrameAPIPort extends EventDispatcher {
    /**
     * @param {number} otherTabId
     * @param {number} otherFrameId
     * @param {chrome.runtime.Port} port
     * @param {import('core').MessageHandlerMap} messageHandlers
     */
    constructor(otherTabId, otherFrameId, port, messageHandlers) {
        super();
        /** @type {number} */
        this._otherTabId = otherTabId;
        /** @type {number} */
        this._otherFrameId = otherFrameId;
        /** @type {?chrome.runtime.Port} */
        this._port = port;
        /** @type {import('core').MessageHandlerMap} */
        this._messageHandlers = messageHandlers;
        /** @type {Map<number, import('cross-frame-api').Invocation>} */
        this._activeInvocations = new Map();
        /** @type {number} */
        this._invocationId = 0;
        /** @type {EventListenerCollection} */
        this._eventListeners = new EventListenerCollection();
    }

    /** @type {number} */
    get otherTabId() {
        return this._otherTabId;
    }

    /** @type {number} */
    get otherFrameId() {
        return this._otherFrameId;
    }

    /**
     * @throws {Error}
     */
    prepare() {
        if (this._port === null) { throw new Error('Invalid state'); }
        this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this));
        this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this));
    }

    /**
     * @template [TParams=import('core').SerializableObject]
     * @template [TReturn=unknown]
     * @param {string} action
     * @param {TParams} params
     * @param {number} ackTimeout
     * @param {number} responseTimeout
     * @returns {Promise<TReturn>}
     */
    invoke(action, params, ackTimeout, responseTimeout) {
        return new Promise((resolve, reject) => {
            if (this._port === null) {
                reject(new Error(`Port is disconnected (${action})`));
                return;
            }

            const id = this._invocationId++;
            /** @type {import('cross-frame-api').Invocation} */
            const invocation = {
                id,
                resolve,
                reject,
                responseTimeout,
                action,
                ack: false,
                timer: null
            };
            this._activeInvocations.set(id, invocation);

            if (ackTimeout !== null) {
                try {
                    invocation.timer = setTimeout(() => this._onError(id, 'Acknowledgement timeout'), ackTimeout);
                } catch (e) {
                    this._onError(id, 'Failed to set timeout');
                    return;
                }
            }

            try {
                this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}}));
            } catch (e) {
                this._onError(id, e);
            }
        });
    }

    /** */
    disconnect() {
        this._onDisconnect();
    }

    // Private

    /** */
    _onDisconnect() {
        if (this._port === null) { return; }
        this._eventListeners.removeAllEventListeners();
        this._port = null;
        for (const id of this._activeInvocations.keys()) {
            this._onError(id, 'Disconnected');
        }
        this.trigger('disconnect', this);
    }

    /**
     * @param {import('cross-frame-api').Message} details
     */
    _onMessage(details) {
        const {type, id} = details;
        switch (type) {
            case 'invoke':
                this._onInvoke(id, details.data);
                break;
            case 'ack':
                this._onAck(id);
                break;
            case 'result':
                this._onResult(id, details.data);
                break;
        }
    }

    // Response handlers

    /**
     * @param {number} id
     */
    _onAck(id) {
        const invocation = this._activeInvocations.get(id);
        if (typeof invocation === 'undefined') {
            log.warn(new Error(`Request ${id} not found for acknowledgement`));
            return;
        }

        if (invocation.ack) {
            this._onError(id, `Request ${id} already acknowledged`);
            return;
        }

        invocation.ack = true;

        if (invocation.timer !== null) {
            clearTimeout(invocation.timer);
            invocation.timer = null;
        }

        const responseTimeout = invocation.responseTimeout;
        if (responseTimeout !== null) {
            try {
                invocation.timer = setTimeout(() => this._onError(id, 'Response timeout'), responseTimeout);
            } catch (e) {
                this._onError(id, 'Failed to set timeout');
            }
        }
    }

    /**
     * @param {number} id
     * @param {import('core').Response<unknown>} data
     */
    _onResult(id, data) {
        const invocation = this._activeInvocations.get(id);
        if (typeof invocation === 'undefined') {
            log.warn(new Error(`Request ${id} not found`));
            return;
        }

        if (!invocation.ack) {
            this._onError(id, `Request ${id} not acknowledged`);
            return;
        }

        this._activeInvocations.delete(id);

        if (invocation.timer !== null) {
            clearTimeout(invocation.timer);
            invocation.timer = null;
        }

        const error = data.error;
        if (typeof error !== 'undefined') {
            invocation.reject(ExtensionError.deserialize(error));
        } else {
            invocation.resolve(data.result);
        }
    }

    /**
     * @param {number} id
     * @param {unknown} error
     */
    _onError(id, error) {
        const invocation = this._activeInvocations.get(id);
        if (typeof invocation === 'undefined') { return; }

        if (!(error instanceof Error)) {
            error = new Error(`${error} (${invocation.action})`);
        }

        this._activeInvocations.delete(id);
        if (invocation.timer !== null) {
            clearTimeout(invocation.timer);
            invocation.timer = null;
        }
        invocation.reject(error);
    }

    // Invocation

    /**
     * @param {number} id
     * @param {import('cross-frame-api').InvocationData} details
     * @returns {boolean}
     */
    _onInvoke(id, {action, params}) {
        const messageHandler = this._messageHandlers.get(action);
        this._sendAck(id);
        if (typeof messageHandler === 'undefined') {
            this._sendError(id, new Error(`Unknown action: ${action}`));
            return false;
        }

        /**
         * @param {import('core').Response<unknown>} data
         * @returns {void}
         */
        const callback = (data) => this._sendResult(id, data);
        return invokeMessageHandler(messageHandler, params, callback);
    }

    /**
     * @param {import('cross-frame-api').Message} data
     */
    _sendResponse(data) {
        if (this._port === null) { return; }
        try {
            this._port.postMessage(data);
        } catch (e) {
            // NOP
        }
    }

    /**
     * @param {number} id
     */
    _sendAck(id) {
        this._sendResponse({type: 'ack', id});
    }

    /**
     * @param {number} id
     * @param {import('core').Response<unknown>} data
     */
    _sendResult(id, data) {
        this._sendResponse({type: 'result', id, data});
    }

    /**
     * @param {number} id
     * @param {Error} error
     */
    _sendError(id, error) {
        this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}});
    }
}

export class CrossFrameAPI {
    constructor() {
        /** @type {number} */
        this._ackTimeout = 3000; // 3 seconds
        /** @type {number} */
        this._responseTimeout = 10000; // 10 seconds
        /** @type {Map<number, Map<number, CrossFrameAPIPort>>} */
        this._commPorts = new Map();
        /** @type {import('core').MessageHandlerMap} */
        this._messageHandlers = new Map();
        /** @type {(port: CrossFrameAPIPort) => void} */
        this._onDisconnectBind = this._onDisconnect.bind(this);
        /** @type {?number} */
        this._tabId = null;
        /** @type {?number} */
        this._frameId = null;
    }

    /** */
    async prepare() {
        chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
        ({tabId: this._tabId = null, frameId: this._frameId = null} = await yomitan.api.frameInformationGet());
    }

    /**
     * @template [TParams=import('core').SerializableObject]
     * @template [TReturn=unknown]
     * @param {number} targetFrameId
     * @param {string} action
     * @param {TParams} params
     * @returns {Promise<TReturn>}
     */
    invoke(targetFrameId, action, params) {
        return this.invokeTab(null, targetFrameId, action, params);
    }

    /**
     * @template [TParams=import('core').SerializableObject]
     * @template [TReturn=unknown]
     * @param {?number} targetTabId
     * @param {number} targetFrameId
     * @param {string} action
     * @param {TParams} params
     * @returns {Promise<TReturn>}
     */
    async invokeTab(targetTabId, targetFrameId, action, params) {
        if (typeof targetTabId !== 'number') {
            targetTabId = this._tabId;
            if (typeof targetTabId !== 'number') {
                throw new Error('Unknown target tab id for invocation');
            }
        }
        const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId);
        return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout);
    }

    /**
     * @param {import('core').MessageHandlerArray} messageHandlers
     * @throws {Error}
     */
    registerHandlers(messageHandlers) {
        for (const [key, value] of messageHandlers) {
            if (this._messageHandlers.has(key)) {
                throw new Error(`Handler ${key} is already registered`);
            }
            this._messageHandlers.set(key, value);
        }
    }

    /**
     * @param {string} key
     * @returns {boolean}
     */
    unregisterHandler(key) {
        return this._messageHandlers.delete(key);
    }

    // Private

    /**
     * @param {chrome.runtime.Port} port
     */
    _onConnect(port) {
        try {
            let details;
            try {
                details = JSON.parse(port.name);
            } catch (e) {
                return;
            }
            if (details.name !== 'cross-frame-communication-port') { return; }

            const otherTabId = details.otherTabId;
            const otherFrameId = details.otherFrameId;
            this._setupCommPort(otherTabId, otherFrameId, port);
        } catch (e) {
            port.disconnect();
            log.error(e);
        }
    }

    /**
     * @param {CrossFrameAPIPort} commPort
     */
    _onDisconnect(commPort) {
        commPort.off('disconnect', this._onDisconnectBind);
        const {otherTabId, otherFrameId} = commPort;
        const tabPorts = this._commPorts.get(otherTabId);
        if (typeof tabPorts !== 'undefined') {
            tabPorts.delete(otherFrameId);
            if (tabPorts.size === 0) {
                this._commPorts.delete(otherTabId);
            }
        }
    }

    /**
     * @param {number} otherTabId
     * @param {number} otherFrameId
     * @returns {Promise<CrossFrameAPIPort>}
     */
    async _getOrCreateCommPort(otherTabId, otherFrameId) {
        const tabPorts = this._commPorts.get(otherTabId);
        if (typeof tabPorts !== 'undefined') {
            const commPort = tabPorts.get(otherFrameId);
            if (typeof commPort !== 'undefined') {
                return commPort;
            }
        }
        return await this._createCommPort(otherTabId, otherFrameId);
    }
    /**
     * @param {number} otherTabId
     * @param {number} otherFrameId
     * @returns {Promise<CrossFrameAPIPort>}
     */
    async _createCommPort(otherTabId, otherFrameId) {
        await yomitan.api.openCrossFramePort(otherTabId, otherFrameId);

        const tabPorts = this._commPorts.get(otherTabId);
        if (typeof tabPorts !== 'undefined') {
            const commPort = tabPorts.get(otherFrameId);
            if (typeof commPort !== 'undefined') {
                return commPort;
            }
        }
        throw new Error('Comm port didn\'t open');
    }

    /**
     * @param {number} otherTabId
     * @param {number} otherFrameId
     * @param {chrome.runtime.Port} port
     * @returns {CrossFrameAPIPort}
     */
    _setupCommPort(otherTabId, otherFrameId, port) {
        const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._messageHandlers);
        let tabPorts = this._commPorts.get(otherTabId);
        if (typeof tabPorts === 'undefined') {
            tabPorts = new Map();
            this._commPorts.set(otherTabId, tabPorts);
        }
        tabPorts.set(otherFrameId, commPort);
        commPort.prepare();
        commPort.on('disconnect', this._onDisconnectBind);
        return commPort;
    }
}