/*
 * 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 {extendApiMap, invokeApiMapHandler} from '../core/api-map.js';
import {EventDispatcher} from '../core/event-dispatcher.js';
import {EventListenerCollection} from '../core/event-listener-collection.js';
import {ExtensionError} from '../core/extension-error.js';
import {parseJson} from '../core/json.js';
import {log} from '../core/log.js';

/**
 * @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEvents>
 */
export class CrossFrameAPIPort extends EventDispatcher {
    /**
     * @param {number} otherTabId
     * @param {number} otherFrameId
     * @param {chrome.runtime.Port} port
     * @param {import('cross-frame-api').ApiMap} apiMap
     */
    constructor(otherTabId, otherFrameId, port, apiMap) {
        super();
        /** @type {number} */
        this._otherTabId = otherTabId;
        /** @type {number} */
        this._otherFrameId = otherFrameId;
        /** @type {?chrome.runtime.Port} */
        this._port = port;
        /** @type {import('cross-frame-api').ApiMap} */
        this._apiMap = apiMap;
        /** @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 {import('cross-frame-api').ApiNames} TName
     * @param {TName} action
     * @param {import('cross-frame-api').ApiParams<TName>} params
     * @param {number} ackTimeout
     * @param {number} responseTimeout
     * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
     */
    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<import('cross-frame-api').ApiReturnAny>} 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} errorOrMessage
     */
    _onError(id, errorOrMessage) {
        const invocation = this._activeInvocations.get(id);
        if (typeof invocation === 'undefined') { return; }

        const error = errorOrMessage instanceof Error ? errorOrMessage : new Error(`${errorOrMessage} (${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').ApiMessageAny} details
     */
    _onInvoke(id, {action, params}) {
        this._sendAck(id);
        invokeApiMapHandler(
            this._apiMap,
            action,
            params,
            [],
            (data) => this._sendResult(id, data),
            () => this._sendError(id, new Error(`Unknown action: ${action}`))
        );
    }

    /**
     * @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<import('cross-frame-api').ApiReturnAny>} 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 {
    /**
     * @param {import('../comm/api.js').API} api
     * @param {?number} tabId
     * @param {?number} frameId
     */
    constructor(api, tabId, frameId) {
        /** @type {import('../comm/api.js').API} */
        this._api = api;
        /** @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('cross-frame-api').ApiMap} */
        this._apiMap = new Map();
        /** @type {(port: CrossFrameAPIPort) => void} */
        this._onDisconnectBind = this._onDisconnect.bind(this);
        /** @type {?number} */
        this._tabId = tabId;
        /** @type {?number} */
        this._frameId = frameId;
    }

    /**
     * @type {?number}
     */
    get tabId() {
        return this._tabId;
    }

    /**
     * @type {?number}
     */
    get frameId() {
        return this._frameId;
    }

    /** */
    prepare() {
        chrome.runtime.onConnect.addListener(this._onConnect.bind(this));
    }

    /**
     * @template {import('cross-frame-api').ApiNames} TName
     * @param {number} targetFrameId
     * @param {TName} action
     * @param {import('cross-frame-api').ApiParams<TName>} params
     * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
     */
    invoke(targetFrameId, action, params) {
        return this.invokeTab(null, targetFrameId, action, params);
    }

    /**
     * @template {import('cross-frame-api').ApiNames} TName
     * @param {?number} targetTabId
     * @param {number} targetFrameId
     * @param {TName} action
     * @param {import('cross-frame-api').ApiParams<TName>} params
     * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>}
     */
    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('cross-frame-api').ApiMapInit} handlers
     */
    registerHandlers(handlers) {
        extendApiMap(this._apiMap, handlers);
    }

    // Private

    /**
     * @param {chrome.runtime.Port} port
     */
    _onConnect(port) {
        try {
            /** @type {import('cross-frame-api').PortDetails} */
            let details;
            try {
                details = parseJson(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 this._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._apiMap);
        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;
    }
}