/*
 * Copyright (C) 2023  Yomitan Authors
 * Copyright (C) 2016-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 {deferPromise} from '../core.js';
import {ExtensionError} from '../core/extension-error.js';

export class API {
    /**
     * @param {import('../yomitan.js').Yomitan} yomitan
     */
    constructor(yomitan) {
        /** @type {import('../yomitan.js').Yomitan} */
        this._yomitan = yomitan;
    }

    /**
     * @param {import('api').OptionsGetDetails['optionsContext']} optionsContext
     * @returns {Promise<import('api').OptionsGetResult>}
     */
    optionsGet(optionsContext) {
        /** @type {import('api').OptionsGetDetails} */
        const details = {optionsContext};
        return this._invoke('optionsGet', details);
    }

    /**
     * @returns {Promise<import('api').OptionsGetFullResult>}
     */
    optionsGetFull() {
        return this._invoke('optionsGetFull');
    }

    /**
     * @param {import('api').TermsFindDetails['text']} text
     * @param {import('api').TermsFindDetails['details']} details
     * @param {import('api').TermsFindDetails['optionsContext']} optionsContext
     * @returns {Promise<import('api').TermsFindResult>}
     */
    termsFind(text, details, optionsContext) {
        /** @type {import('api').TermsFindDetails} */
        const details2 = {text, details, optionsContext};
        return this._invoke('termsFind', details2);
    }

    /**
     * @param {import('api').ParseTextDetails['text']} text
     * @param {import('api').ParseTextDetails['optionsContext']} optionsContext
     * @param {import('api').ParseTextDetails['scanLength']} scanLength
     * @param {import('api').ParseTextDetails['useInternalParser']} useInternalParser
     * @param {import('api').ParseTextDetails['useMecabParser']} useMecabParser
     * @returns {Promise<import('api').ParseTextResult>}
     */
    parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) {
        /** @type {import('api').ParseTextDetails} */
        const details = {text, optionsContext, scanLength, useInternalParser, useMecabParser};
        return this._invoke('parseText', details);
    }

    /**
     * @param {import('api').KanjiFindDetails['text']} text
     * @param {import('api').KanjiFindDetails['optionsContext']} optionsContext
     * @returns {Promise<import('api').KanjiFindResult>}
     */
    kanjiFind(text, optionsContext) {
        /** @type {import('api').KanjiFindDetails} */
        const details = {text, optionsContext};
        return this._invoke('kanjiFind', details);
    }

    /**
     * @returns {Promise<import('api').IsAnkiConnectedResult>}
     */
    isAnkiConnected() {
        return this._invoke('isAnkiConnected');
    }

    /**
     * @returns {Promise<import('api').GetAnkiConnectVersionResult>}
     */
    getAnkiConnectVersion() {
        return this._invoke('getAnkiConnectVersion');
    }

    /**
     * @param {import('api').AddAnkiNoteDetails['note']} note
     * @returns {Promise<import('api').AddAnkiNoteResult>}
     */
    addAnkiNote(note) {
        /** @type {import('api').AddAnkiNoteDetails} */
        const details = {note};
        return this._invoke('addAnkiNote', details);
    }

    /**
     * @param {import('api').GetAnkiNoteInfoDetails['notes']} notes
     * @param {import('api').GetAnkiNoteInfoDetails['fetchAdditionalInfo']} fetchAdditionalInfo
     * @returns {Promise<import('api').GetAnkiNoteInfoResult>}
     */
    getAnkiNoteInfo(notes, fetchAdditionalInfo) {
        /** @type {import('api').GetAnkiNoteInfoDetails} */
        const details = {notes, fetchAdditionalInfo};
        return this._invoke('getAnkiNoteInfo', details);
    }

    /**
     * @param {import('api').InjectAnkiNoteMediaDetails['timestamp']} timestamp
     * @param {import('api').InjectAnkiNoteMediaDetails['definitionDetails']} definitionDetails
     * @param {import('api').InjectAnkiNoteMediaDetails['audioDetails']} audioDetails
     * @param {import('api').InjectAnkiNoteMediaDetails['screenshotDetails']} screenshotDetails
     * @param {import('api').InjectAnkiNoteMediaDetails['clipboardDetails']} clipboardDetails
     * @param {import('api').InjectAnkiNoteMediaDetails['dictionaryMediaDetails']} dictionaryMediaDetails
     * @returns {Promise<import('api').InjectAnkiNoteMediaResult>}
     */
    injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) {
        /** @type {import('api').InjectAnkiNoteMediaDetails} */
        const details = {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails};
        return this._invoke('injectAnkiNoteMedia', details);
    }

    /**
     * @param {import('api').NoteViewDetails['noteId']} noteId
     * @param {import('api').NoteViewDetails['mode']} mode
     * @param {import('api').NoteViewDetails['allowFallback']} allowFallback
     * @returns {Promise<import('api').NoteViewResult>}
     */
    noteView(noteId, mode, allowFallback) {
        /** @type {import('api').NoteViewDetails} */
        const details = {noteId, mode, allowFallback};
        return this._invoke('noteView', details);
    }

    /**
     * @param {import('api').SuspendAnkiCardsForNoteDetails['noteId']} noteId
     * @returns {Promise<import('api').SuspendAnkiCardsForNoteResult>}
     */
    suspendAnkiCardsForNote(noteId) {
        /** @type {import('api').SuspendAnkiCardsForNoteDetails} */
        const details = {noteId};
        return this._invoke('suspendAnkiCardsForNote', details);
    }

    /**
     * @param {import('api').GetTermAudioInfoListDetails['source']} source
     * @param {import('api').GetTermAudioInfoListDetails['term']} term
     * @param {import('api').GetTermAudioInfoListDetails['reading']} reading
     * @returns {Promise<import('api').GetTermAudioInfoListResult>}
     */
    getTermAudioInfoList(source, term, reading) {
        /** @type {import('api').GetTermAudioInfoListDetails} */
        const details = {source, term, reading};
        return this._invoke('getTermAudioInfoList', details);
    }

    /**
     * @param {import('api').CommandExecDetails['command']} command
     * @param {import('api').CommandExecDetails['params']} [params]
     * @returns {Promise<import('api').CommandExecResult>}
     */
    commandExec(command, params) {
        /** @type {import('api').CommandExecDetails} */
        const details = {command, params};
        return this._invoke('commandExec', details);
    }

    /**
     * @param {import('api').SendMessageToFrameDetails['frameId']} frameId
     * @param {import('api').SendMessageToFrameDetails['action']} action
     * @param {import('api').SendMessageToFrameDetails['params']} [params]
     * @returns {Promise<import('api').SendMessageToFrameResult>}
     */
    sendMessageToFrame(frameId, action, params) {
        /** @type {import('api').SendMessageToFrameDetails} */
        const details = {frameId, action, params};
        return this._invoke('sendMessageToFrame', details);
    }

    /**
     * @param {import('api').BroadcastTabDetails['action']} action
     * @param {import('api').BroadcastTabDetails['params']} params
     * @returns {Promise<import('api').BroadcastTabResult>}
     */
    broadcastTab(action, params) {
        /** @type {import('api').BroadcastTabDetails} */
        const details = {action, params};
        return this._invoke('broadcastTab', details);
    }

    /**
     * @returns {Promise<import('api').FrameInformationGetResult>}
     */
    frameInformationGet() {
        return this._invoke('frameInformationGet');
    }

    /**
     * @param {import('api').InjectStylesheetDetails['type']} type
     * @param {import('api').InjectStylesheetDetails['value']} value
     * @returns {Promise<import('api').InjectStylesheetResult>}
     */
    injectStylesheet(type, value) {
        /** @type {import('api').InjectStylesheetDetails} */
        const details = {type, value};
        return this._invoke('injectStylesheet', details);
    }

    /**
     * @param {import('api').GetStylesheetContentDetails['url']} url
     * @returns {Promise<import('api').GetStylesheetContentResult>}
     */
    getStylesheetContent(url) {
        /** @type {import('api').GetStylesheetContentDetails} */
        const details = {url};
        return this._invoke('getStylesheetContent', details);
    }

    /**
     * @returns {Promise<import('api').GetEnvironmentInfoResult>}
     */
    getEnvironmentInfo() {
        return this._invoke('getEnvironmentInfo');
    }

    /**
     * @returns {Promise<import('api').ClipboardGetResult>}
     */
    clipboardGet() {
        return this._invoke('clipboardGet');
    }

    /**
     * @returns {Promise<import('api').GetDisplayTemplatesHtmlResult>}
     */
    getDisplayTemplatesHtml() {
        return this._invoke('getDisplayTemplatesHtml');
    }

    /**
     * @returns {Promise<import('api').GetZoomResult>}
     */
    getZoom() {
        return this._invoke('getZoom');
    }

    /**
     * @returns {Promise<import('api').GetDefaultAnkiFieldTemplatesResult>}
     */
    getDefaultAnkiFieldTemplates() {
        return this._invoke('getDefaultAnkiFieldTemplates');
    }

    /**
     * @returns {Promise<import('api').GetDictionaryInfoResult>}
     */
    getDictionaryInfo() {
        return this._invoke('getDictionaryInfo');
    }

    /**
     * @returns {Promise<import('api').PurgeDatabaseResult>}
     */
    purgeDatabase() {
        return this._invoke('purgeDatabase');
    }

    /**
     * @param {import('api').GetMediaDetails['targets']} targets
     * @returns {Promise<import('api').GetMediaResult>}
     */
    getMedia(targets) {
        /** @type {import('api').GetMediaDetails} */
        const details = {targets};
        return this._invoke('getMedia', details);
    }

    /**
     * @param {import('api').LogDetails['error']} error
     * @param {import('api').LogDetails['level']} level
     * @param {import('api').LogDetails['context']} context
     * @returns {Promise<import('api').LogResult>}
     */
    log(error, level, context) {
        /** @type {import('api').LogDetails} */
        const details = {error, level, context};
        return this._invoke('log', details);
    }

    /**
     * @returns {Promise<import('api').LogIndicatorClearResult>}
     */
    logIndicatorClear() {
        return this._invoke('logIndicatorClear');
    }

    /**
     * @param {import('api').ModifySettingsDetails['targets']} targets
     * @param {import('api').ModifySettingsDetails['source']} source
     * @returns {Promise<import('api').ModifySettingsResult>}
     */
    modifySettings(targets, source) {
        const details = {targets, source};
        return this._invoke('modifySettings', details);
    }

    /**
     * @param {import('api').GetSettingsDetails['targets']} targets
     * @returns {Promise<import('api').GetSettingsResult>}
     */
    getSettings(targets) {
        /** @type {import('api').GetSettingsDetails} */
        const details = {targets};
        return this._invoke('getSettings', details);
    }

    /**
     * @param {import('api').SetAllSettingsDetails['value']} value
     * @param {import('api').SetAllSettingsDetails['source']} source
     * @returns {Promise<import('api').SetAllSettingsResult>}
     */
    setAllSettings(value, source) {
        /** @type {import('api').SetAllSettingsDetails} */
        const details = {value, source};
        return this._invoke('setAllSettings', details);
    }

    /**
     * @param {import('api').GetOrCreateSearchPopupDetails} details
     * @returns {Promise<import('api').GetOrCreateSearchPopupResult>}
     */
    getOrCreateSearchPopup(details) {
        return this._invoke('getOrCreateSearchPopup', details);
    }

    /**
     * @param {import('api').IsTabSearchPopupDetails['tabId']} tabId
     * @returns {Promise<import('api').IsTabSearchPopupResult>}
     */
    isTabSearchPopup(tabId) {
        /** @type {import('api').IsTabSearchPopupDetails} */
        const details = {tabId};
        return this._invoke('isTabSearchPopup', details);
    }

    /**
     * @param {import('api').TriggerDatabaseUpdatedDetails['type']} type
     * @param {import('api').TriggerDatabaseUpdatedDetails['cause']} cause
     * @returns {Promise<import('api').TriggerDatabaseUpdatedResult>}
     */
    triggerDatabaseUpdated(type, cause) {
        /** @type {import('api').TriggerDatabaseUpdatedDetails} */
        const details = {type, cause};
        return this._invoke('triggerDatabaseUpdated', details);
    }

    /**
     * @returns {Promise<import('api').TestMecabResult>}
     */
    testMecab() {
        return this._invoke('testMecab');
    }

    /**
     * @param {import('api').TextHasJapaneseCharactersDetails['text']} text
     * @returns {Promise<import('api').TextHasJapaneseCharactersResult>}
     */
    textHasJapaneseCharacters(text) {
        /** @type {import('api').TextHasJapaneseCharactersDetails} */
        const details = {text};
        return this._invoke('textHasJapaneseCharacters', details);
    }

    /**
     * @param {import('api').GetTermFrequenciesDetails['termReadingList']} termReadingList
     * @param {import('api').GetTermFrequenciesDetails['dictionaries']} dictionaries
     * @returns {Promise<import('api').GetTermFrequenciesResult>}
     */
    getTermFrequencies(termReadingList, dictionaries) {
        /** @type {import('api').GetTermFrequenciesDetails} */
        const details = {termReadingList, dictionaries};
        return this._invoke('getTermFrequencies', details);
    }

    /**
     * @param {import('api').FindAnkiNotesDetails['query']} query
     * @returns {Promise<import('api').FindAnkiNotesResult>}
     */
    findAnkiNotes(query) {
        /** @type {import('api').FindAnkiNotesDetails} */
        const details = {query};
        return this._invoke('findAnkiNotes', details);
    }

    /**
     * @param {import('api').LoadExtensionScriptsDetails['files']} files
     * @returns {Promise<import('api').LoadExtensionScriptsResult>}
     */
    loadExtensionScripts(files) {
        /** @type {import('api').LoadExtensionScriptsDetails} */
        const details = {files};
        return this._invoke('loadExtensionScripts', details);
    }

    /**
     * @param {import('api').OpenCrossFramePortDetails['targetTabId']} targetTabId
     * @param {import('api').OpenCrossFramePortDetails['targetFrameId']} targetFrameId
     * @returns {Promise<import('api').OpenCrossFramePortResult>}
     */
    openCrossFramePort(targetTabId, targetFrameId) {
        return this._invoke('openCrossFramePort', {targetTabId, targetFrameId});
    }

    // Utilities

    /**
     * @param {number} timeout
     * @returns {Promise<chrome.runtime.Port>}
     */
    _createActionPort(timeout) {
        return new Promise((resolve, reject) => {
            /** @type {?number} */
            let timer = null;
            const portDetails = deferPromise();

            /**
             * @param {chrome.runtime.Port} port
             */
            const onConnect = async (port) => {
                try {
                    const {name: expectedName, id: expectedId} = await portDetails.promise;
                    const {name, id} = JSON.parse(port.name);
                    if (name !== expectedName || id !== expectedId || timer === null) { return; }
                } catch (e) {
                    return;
                }

                clearTimeout(timer);
                timer = null;

                chrome.runtime.onConnect.removeListener(onConnect);
                resolve(port);
            };

            /**
             * @param {Error} e
             */
            const onError = (e) => {
                if (timer !== null) {
                    clearTimeout(timer);
                    timer = null;
                }
                chrome.runtime.onConnect.removeListener(onConnect);
                portDetails.reject(e);
                reject(e);
            };

            timer = setTimeout(() => onError(new Error('Timeout')), timeout);

            chrome.runtime.onConnect.addListener(onConnect);
            this._invoke('createActionPort').then(portDetails.resolve, onError);
        });
    }

    /**
     * @template [TReturn=unknown]
     * @param {string} action
     * @param {import('core').SerializableObject} params
     * @param {?(...args: unknown[]) => void} onProgress0
     * @param {number} [timeout]
     * @returns {Promise<TReturn>}
     */
    _invokeWithProgress(action, params, onProgress0, timeout=5000) {
        return new Promise((resolve, reject) => {
            /** @type {?chrome.runtime.Port} */
            let port = null;

            const onProgress = typeof onProgress0 === 'function' ? onProgress0 : () => {};

            /**
             * @param {import('backend').InvokeWithProgressResponseMessage<TReturn>} message
             */
            const onMessage = (message) => {
                switch (message.type) {
                    case 'progress':
                        try {
                            onProgress(...message.data);
                        } catch (e) {
                            // NOP
                        }
                        break;
                    case 'complete':
                        cleanup();
                        resolve(message.data);
                        break;
                    case 'error':
                        cleanup();
                        reject(ExtensionError.deserialize(message.data));
                        break;
                }
            };

            const onDisconnect = () => {
                cleanup();
                reject(new Error('Disconnected'));
            };

            const cleanup = () => {
                if (port !== null) {
                    port.onMessage.removeListener(onMessage);
                    port.onDisconnect.removeListener(onDisconnect);
                    port.disconnect();
                    port = null;
                }
            };

            (async () => {
                try {
                    port = await this._createActionPort(timeout);
                    port.onMessage.addListener(onMessage);
                    port.onDisconnect.addListener(onDisconnect);

                    // Chrome has a maximum message size that can be sent, so longer messages must be fragmented.
                    const messageString = JSON.stringify({action, params});
                    const fragmentSize = 1e7; // 10 MB
                    for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) {
                        const data = messageString.substring(i, i + fragmentSize);
                        port.postMessage(/** @type {import('backend').InvokeWithProgressRequestFragmentMessage} */ ({action: 'fragment', data}));
                    }
                    port.postMessage(/** @type {import('backend').InvokeWithProgressRequestInvokeMessage} */ ({action: 'invoke'}));
                } catch (e) {
                    cleanup();
                    reject(e);
                }
            })();
        });
    }

    /**
     * @template [TReturn=unknown]
     * @param {string} action
     * @param {import('core').SerializableObject} [params]
     * @returns {Promise<TReturn>}
     */
    _invoke(action, params={}) {
        const data = {action, params};
        return new Promise((resolve, reject) => {
            try {
                this._yomitan.sendMessage(data, (response) => {
                    this._checkLastError(chrome.runtime.lastError);
                    if (response !== null && typeof response === 'object') {
                        if (typeof response.error !== 'undefined') {
                            reject(ExtensionError.deserialize(response.error));
                        } else {
                            resolve(response.result);
                        }
                    } else {
                        const message = response === null ? 'Unexpected null response' : `Unexpected response of type ${typeof response}`;
                        reject(new Error(`${message} (${JSON.stringify(data)})`));
                    }
                });
            } catch (e) {
                reject(e);
            }
        });
    }

    /**
     * @param {chrome.runtime.LastError|undefined} _ignore
     */
    _checkLastError(_ignore) {
        // NOP
    }
}