/*
 * Copyright (C) 2023  Yomitan Authors
 * Copyright (C) 2021-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 {isObject} from '../core.js';

/**
 * This class is used to manage script injection into content tabs.
 */
export class ScriptManager {
    /**
     * Creates a new instance of the class.
     */
    constructor() {
        /** @type {Map<string, ?browser.contentScripts.RegisteredContentScript>} */
        this._contentScriptRegistrations = new Map();
    }

    /**
     * Injects a stylesheet into a tab.
     * @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'.
     * @param {string} content The content to inject.
     *   If type is 'file', this argument should be a path to a file.
     *   If type is 'code', this argument should be the CSS content.
     * @param {number} tabId The id of the tab to inject into.
     * @param {number|undefined} frameId The id of the frame to inject into.
     * @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames.
     * @returns {Promise<void>}
     */
    injectStylesheet(type, content, tabId, frameId, allFrames) {
        if (isObject(chrome.scripting) && typeof chrome.scripting.insertCSS === 'function') {
            return this._injectStylesheetMV3(type, content, tabId, frameId, allFrames);
        } else {
            return Promise.reject(new Error('Stylesheet injection not supported'));
        }
    }

    /**
     * Injects a script into a tab.
     * @param {string} file The path to a file to inject.
     * @param {number} tabId The id of the tab to inject into.
     * @param {number|undefined} frameId The id of the frame to inject into.
     * @param {boolean} allFrames Whether or not the script should be injected into all frames.
     * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection.
     */
    injectScript(file, tabId, frameId, allFrames) {
        if (isObject(chrome.scripting) && typeof chrome.scripting.executeScript === 'function') {
            return this._injectScriptMV3(file, tabId, frameId, allFrames);
        } else {
            return Promise.reject(new Error('Script injection not supported'));
        }
    }

    /**
     * Checks whether or not a content script is registered.
     * @param {string} id The identifier used with a call to `registerContentScript`.
     * @returns {Promise<boolean>} `true` if a script is registered, `false` otherwise.
     */
    async isContentScriptRegistered(id) {
        if (this._contentScriptRegistrations.has(id)) {
            return true;
        }
        if (isObject(chrome.scripting) && typeof chrome.scripting.getRegisteredContentScripts === 'function') {
            const scripts = await new Promise((resolve, reject) => {
                chrome.scripting.getRegisteredContentScripts({ids: [id]}, (result) => {
                    const e = chrome.runtime.lastError;
                    if (e) {
                        reject(new Error(e.message));
                    } else {
                        resolve(result);
                    }
                });
            });
            for (const script of scripts) {
                if (script.id === id) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Registers a dynamic content script.
     * Note: if the fallback handler is used and the 'webNavigation' permission isn't granted,
     * there is a possibility that the script can be injected more than once due to the events used.
     * Therefore, a reentrant check may need to be performed by the content script.
     * @param {string} id A unique identifier for the registration.
     * @param {import('script-manager').RegistrationDetails} details The script registration details.
     * @throws An error is thrown if the id is already in use.
     */
    async registerContentScript(id, details) {
        if (await this.isContentScriptRegistered(id)) {
            throw new Error('Registration already exists');
        }

        if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') {
            const details2 = this._createContentScriptRegistrationOptionsChrome(details, id);
            await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
                chrome.scripting.registerContentScripts([details2], () => {
                    const e = chrome.runtime.lastError;
                    if (e) {
                        reject(new Error(e.message));
                    } else {
                        resolve();
                    }
                });
            }));
            this._contentScriptRegistrations.set(id, null);
            return;
        }

        // Fallback
        this._registerContentScriptFallback(id, details);
    }

    /**
     * Unregisters a previously registered content script.
     * @param {string} id The identifier passed to a previous call to `registerContentScript`.
     * @returns {Promise<boolean>} `true` if the content script was unregistered, `false` otherwise.
     */
    async unregisterContentScript(id) {
        if (isObject(chrome.scripting) && typeof chrome.scripting.unregisterContentScripts === 'function') {
            this._contentScriptRegistrations.delete(id);
            try {
                await this._unregisterContentScriptMV3(id);
                return true;
            } catch (e) {
                return false;
            }
        }

        // Fallback
        const registration = this._contentScriptRegistrations.get(id);
        if (typeof registration === 'undefined') { return false; }
        this._contentScriptRegistrations.delete(id);
        if (registration !== null && typeof registration.unregister === 'function') {
            await registration.unregister();
        }
        return true;
    }

    /**
     * Gets the optional permissions required to register a content script.
     * @returns {string[]} An array of the required permissions, which may be empty.
     */
    getRequiredContentScriptRegistrationPermissions() {
        if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') {
            return [];
        }

        // Fallback
        return ['webNavigation'];
    }

    // Private

    /**
     * @param {'file'|'code'} type
     * @param {string} content
     * @param {number} tabId
     * @param {number|undefined} frameId
     * @param {boolean} allFrames
     * @returns {Promise<void>}
     */
    _injectStylesheetMV3(type, content, tabId, frameId, allFrames) {
        return new Promise((resolve, reject) => {
            /** @type {chrome.scripting.InjectionTarget} */
            const target = {
                tabId,
                allFrames
            };
            /** @type {chrome.scripting.CSSInjection} */
            const details = (
                type === 'file' ?
                {origin: 'AUTHOR', files: [content], target} :
                {origin: 'USER', css: content, target}
            );
            if (!allFrames && typeof frameId === 'number') {
                details.target.frameIds = [frameId];
            }
            chrome.scripting.insertCSS(details, () => {
                const e = chrome.runtime.lastError;
                if (e) {
                    reject(new Error(e.message));
                } else {
                    resolve();
                }
            });
        });
    }

    /**
     * @param {string} file
     * @param {number} tabId
     * @param {number|undefined} frameId
     * @param {boolean} allFrames
     * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection.
     */
    _injectScriptMV3(file, tabId, frameId, allFrames) {
        return new Promise((resolve, reject) => {
            /** @type {chrome.scripting.ScriptInjection<unknown[], unknown>} */
            const details = {
                injectImmediately: true,
                files: [file],
                target: {tabId, allFrames}
            };
            if (!allFrames && typeof frameId === 'number') {
                details.target.frameIds = [frameId];
            }
            chrome.scripting.executeScript(details, (results) => {
                const e = chrome.runtime.lastError;
                if (e) {
                    reject(new Error(e.message));
                } else {
                    const {frameId: frameId2, result} = results[0];
                    resolve({frameId: frameId2, result});
                }
            });
        });
    }

    /**
     * @param {string} id
     * @returns {Promise<void>}
     */
    _unregisterContentScriptMV3(id) {
        return new Promise((resolve, reject) => {
            chrome.scripting.unregisterContentScripts({ids: [id]}, () => {
                const e = chrome.runtime.lastError;
                if (e) {
                    reject(new Error(e.message));
                } else {
                    resolve();
                }
            });
        });
    }

    /**
     * @param {import('script-manager').RegistrationDetails} details
     * @returns {browser.contentScripts.RegisteredContentScriptOptions}
     */
    _createContentScriptRegistrationOptionsFirefox(details) {
        const {css, js, matchAboutBlank} = details;
        /** @type {browser.contentScripts.RegisteredContentScriptOptions} */
        const options = {};
        if (typeof matchAboutBlank !== 'undefined') {
            options.matchAboutBlank = matchAboutBlank;
        }
        if (Array.isArray(css)) {
            options.css = css.map((file) => ({file}));
        }
        if (Array.isArray(js)) {
            options.js = js.map((file) => ({file}));
        }
        this._initializeContentScriptRegistrationOptionsGeneric(details, options);
        return options;
    }

    /**
     * @param {import('script-manager').RegistrationDetails} details
     * @param {string} id
     * @returns {chrome.scripting.RegisteredContentScript}
     */
    _createContentScriptRegistrationOptionsChrome(details, id) {
        const {css, js} = details;
        /** @type {chrome.scripting.RegisteredContentScript} */
        const options = {
            id: id,
            persistAcrossSessions: true
        };
        if (Array.isArray(css)) {
            options.css = [...css];
        }
        if (Array.isArray(js)) {
            options.js = [...js];
        }
        this._initializeContentScriptRegistrationOptionsGeneric(details, options);
        return options;
    }

    /**
     * @param {import('script-manager').RegistrationDetails} details
     * @param {chrome.scripting.RegisteredContentScript|browser.contentScripts.RegisteredContentScriptOptions} options
     */
    _initializeContentScriptRegistrationOptionsGeneric(details, options) {
        const {allFrames, excludeMatches, matches, runAt} = details;
        if (typeof allFrames !== 'undefined') {
            options.allFrames = allFrames;
        }
        if (Array.isArray(excludeMatches)) {
            options.excludeMatches = [...excludeMatches];
        }
        if (Array.isArray(matches)) {
            options.matches = [...matches];
        }
        if (typeof runAt !== 'undefined') {
            options.runAt = runAt;
        }
    }

    /**
     * @param {string[]} array
     * @param {boolean} firefoxConvention
     * @returns {string[]|browser.extensionTypes.ExtensionFileOrCode[]}
     */
    _convertFileArray(array, firefoxConvention) {
        return firefoxConvention ? array.map((file) => ({file})) : [...array];
    }

    /**
     * @param {string} id
     * @param {import('script-manager').RegistrationDetails} details
     */
    _registerContentScriptFallback(id, details) {
        const {allFrames, css, js, matchAboutBlank, runAt, urlMatches} = details;
        /** @type {import('script-manager').ContentScriptInjectionDetails} */
        const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: /** @type {?RegExp} */ (null)};
        /** @type {() => Promise<void>} */
        let unregister;
        const webNavigationEvent = this._getWebNavigationEvent(runAt);
        if (typeof webNavigationEvent === 'object' && webNavigationEvent !== null) {
            /**
             * @param {chrome.webNavigation.WebNavigationFramedCallbackDetails} details
             */
            const onTabCommitted = ({url, tabId, frameId}) => {
                this._injectContentScript(true, details2, null, url, tabId, frameId);
            };
            const filter = {url: [{urlMatches}]};
            webNavigationEvent.addListener(onTabCommitted, filter);
            unregister = async () => webNavigationEvent.removeListener(onTabCommitted);
        } else {
            /**
             * @param {number} tabId
             * @param {chrome.tabs.TabChangeInfo} changeInfo
             * @param {chrome.tabs.Tab} tab
             */
            const onTabUpdated = (tabId, {status}, {url}) => {
                if (typeof status === 'string' && typeof url === 'string') {
                    this._injectContentScript(false, details2, status, url, tabId, void 0);
                }
            };
            try {
                // Firefox
                /** @type {browser.tabs.UpdateFilter} */
                const extraParameters = {urls: [urlMatches], properties: ['status']};
                browser.tabs.onUpdated.addListener(
                    /** @type {(tabId: number, changeInfo: browser.tabs._OnUpdatedChangeInfo, tab: browser.tabs.Tab) => void} */ (onTabUpdated),
                    extraParameters
                );
            } catch (e) {
                // Chrome
                details2.urlRegex = new RegExp(urlMatches);
                chrome.tabs.onUpdated.addListener(onTabUpdated);
            }
            unregister = async () => chrome.tabs.onUpdated.removeListener(onTabUpdated);
        }
        this._contentScriptRegistrations.set(id, {unregister});
    }

    /**
     * @param {import('script-manager').RunAt} runAt
     * @returns {?(chrome.webNavigation.WebNavigationFramedEvent|chrome.webNavigation.WebNavigationTransitionalEvent)}
     */
    _getWebNavigationEvent(runAt) {
        const {webNavigation} = chrome;
        if (!isObject(webNavigation)) { return null; }
        switch (runAt) {
            case 'document_start':
                return webNavigation.onCommitted;
            case 'document_end':
                return webNavigation.onDOMContentLoaded;
            default: // 'document_idle':
                return webNavigation.onCompleted;
        }
    }

    /**
     * @param {boolean} isWebNavigation
     * @param {import('script-manager').ContentScriptInjectionDetails} details
     * @param {?string} status
     * @param {string} url
     * @param {number} tabId
     * @param {number|undefined} frameId
     */
    async _injectContentScript(isWebNavigation, details, status, url, tabId, frameId) {
        const {urlRegex} = details;
        if (urlRegex !== null && !urlRegex.test(url)) { return; }

        let {allFrames, css, js, runAt} = details;

        if (isWebNavigation) {
            if (allFrames) {
                allFrames = false;
            } else {
                if (frameId !== 0) { return; }
            }
        } else {
            if (runAt === 'document_start') {
                if (status !== 'loading') { return; }
            } else { // 'document_end', 'document_idle'
                if (status !== 'complete') { return; }
            }
        }

        const promises = [];
        if (Array.isArray(css)) {
            for (const file of css) {
                promises.push(this.injectStylesheet('file', file, tabId, frameId, allFrames));
            }
        }
        if (Array.isArray(js)) {
            for (const file of js) {
                promises.push(this.injectScript(file, tabId, frameId, allFrames));
            }
        }
        await Promise.all(promises);
    }
}