/* eslint-disable max-len */
/* eslint-disable no-console */
// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Tiny WidgetHub plugin.
 *
 * @module      tiny_widgethub/plugin
 * @copyright   2026 Josep Mulet Pol <pep.mulet@gmail.com>
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

import { getPluginOptionName } from 'editor_tiny/options';
import Common from './common';
import { compareVersion, genID } from './util';
import { createDefaultsForParam } from './service/template_service';
import { FetchDocumentsBatch } from './service/fetchdocumentsbatch';
import { getContextMenuManager } from './contextactions';
import { getExternalService } from './service/external_service';

const pluginName = Common.pluginName;

const showPluginOptName = getPluginOptionName(pluginName, 'showplugin');
const userInfoOptName = getPluginOptionName(pluginName, 'user');
const courseIdOptName = getPluginOptionName(pluginName, 'courseid');
const shareCssOptName = getPluginOptionName(pluginName, 'sharecss');
const userPrefsOptName = getPluginOptionName(pluginName, 'userprefs');
const moodleVersionOptName = getPluginOptionName(pluginName, 'moodleversion');

/**
 * Wrapped version of the widget definitions shared among all editors in page
 * Store widgetlist and partials as shared among editors in page.
 * @type {{widgetDict: Record<string, Widget> | undefined, widgetList: RawWidget[] | undefined, partials: Record<string, any> | undefined, additionalcss: string | undefined, cfg: Record<string, any> | undefined}}
 */

const _cache = Object.seal(
    Object.assign(Object.create(null), {
        widgetDict: undefined,
        widgetList: undefined,
        partials: undefined,
        additionalcss: undefined,
        cfg: undefined,
    })
);

/** @type {Promise<void> | null} */
let _fetchPromise = null;


/**
 * Singleton fetch for editor data.
 * @returns {Promise<void>}
 */
export const fetchEditorData = () => {
    if (_fetchPromise) {
        return _fetchPromise;
    }
    _fetchPromise = getExternalService().getEditorData().then(data => {
        _cache.widgetList = data.widgetlist;
        _cache.partials = data.partials;
        _cache.additionalcss = data.additionalcss;
        _cache.cfg = data.cfg;
        _cache.widgetDict = undefined;
    });
    return _fetchPromise;
};

/**
 * Test-only helper to populate the shared cache without an AJAX call.
 * @param {object} data
 * @param {RawWidget[]} [data.widgetList]
 * @param {Record<string, any>} [data.partials]
 * @param {string} [data.additionalcss]
 * @param {Record<string, any>} [data.cfg]
 */
export function _resetCacheForTesting(data = {}) {
    _cache.widgetList = data.widgetList ?? undefined;
    _cache.partials = data.partials ?? undefined;
    _cache.additionalcss = data.additionalcss ?? undefined;
    _cache.cfg = data.cfg ?? undefined;
    _cache.widgetDict = undefined;
    _fetchPromise = null;
}


/**
 * @param {import('./plugin').TinyMCE} editor
 */
export const register = (editor) => {
    const registerOption = editor.options.register;

    registerOption(showPluginOptName, {
        processor: 'boolean',
        "default": true,
    });

    registerOption(userInfoOptName, {
        processor: 'object',
        "default": {
            id: 0,
            username: '',
            roles: []
        },
    });

    registerOption(courseIdOptName, {
        processor: 'string',
        "default": "-1",
    });

    registerOption(shareCssOptName, {
        processor: 'boolean',
        "default": true,
    });

    registerOption(userPrefsOptName, {
        processor: 'string',
        "default": '',
    });

    registerOption(moodleVersionOptName, {
        processor: 'string',
        "default": '4.1',
    });
};

/**
 *  Hook to update widgetlist option from administrator widget editor page.
 *
 * @param {import('./plugin').TinyMCE} editor
 * @param {RawWidget} widget - widget to be added to the editor
 * @param {string} css - css to be injected in the editor iframe
 */
export function setWidgetDefinitions(editor, widget, css) {
    if (!_embedRevs) {
        // Initialize _embedRevs before widgets are overwritten.
        getEmbedRevs(editor);
    }
    editor.mode.set(widget ? 'design' : 'readonly');
    _cache.widgetList = widget ? [widget] : [];
    // Clear cache.

    _cache.widgetDict = undefined;
    // Add css into editor's iframe.
    const styleNode = editor.dom.get('tiny_widgethub-playground-style');
    if (styleNode) {
        editor.dom.remove(styleNode);
    }
    editor.dom.add(
        editor.dom.select('head')[0],
        'style',
        { type: 'text/css', id: 'tiny_widgethub-playground-style' },
        css ?? ''
    );
    // Reinit context menu.
    getContextMenuManager(editor).init();
}

/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {boolean} - are the plugin buttons visible?
 */
export const isPluginVisible = (editor) => editor.options.get(showPluginOptName);

/**
 * @returns {string} - additional css that must be included in a <style> tag in editor's iframe
 */
export const getAdditionalCss = () => {
    return _cache.additionalcss ?? '';
};

/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {boolean} - whether site styles should be inserted into iframe
 */
export const isShareCss = (editor) => {
    return editor.options.get(shareCssOptName);
};

/**
 * @returns {boolean} - whether the editor is in playground mode
 */
export const isPlaygroundMode = () => {
    return document.body.classList.contains('tiny_widgethub-playground');
};

/**
 * @param {import('./plugin').TinyMCE} editor
 * @param {string} key
 * @param {string} defaultValue
 * @returns {string} - An object with the key/value properties
 */
export const getGlobalConfig = (editor, key, defaultValue) => {
    const dict = _cache.cfg ?? {};
    return dict[key]?.trim() ?? defaultValue;
};

/**
 * @returns {Object<string, string>} - An object with the key/value properties
 */
export const getPartials = () => {
    return _cache.partials ?? {};
};


/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {string | null} - user preferences
 */
export const getUserPrefs = (editor) => {
    return editor.options.get(userPrefsOptName);
};

/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {string} - moodle version
 */
export const getMoodleVersion = (editor) => {
    return editor.options.get(moodleVersionOptName);
};

/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {Record<string, Widget>} - The available list of widgets indexed by key
 */
export const getWidgetDict = (editor) => {
    if (_cache.widgetDict) {
        return _cache.widgetDict;
    }
    /** @type {RawWidget[]} */
    const rawWidgets = _cache.widgetList ?? [];
    /** @type {Record<string, Widget>} */
    const widgetDict = Object.create(null);
    // Partials is a special widget that is used to define common parameters shared by other widgets
    const partials = getPartials();

    // Create a wrapper for the widget to handle operations
    const wrappedWidgets = rawWidgets.map(w => new Widget(w, partials));

    // Remove those widgets that aren't usable for the current user
    // and not supported by the currentVersion of the plugin.
    const userInfo = editor.options.get(userInfoOptName);
    wrappedWidgets.filter(w => w.isFor(userInfo) && compareVersion(Common.currentRelease, w.prop('plugin_release')))
        .forEach(w => { widgetDict[w.key] = w; });

    _cache.widgetDict = widgetDict;
    return widgetDict;
};

/** @type {Record<string, {id: number, lastmodified: number}> | null} */
let _embedRevs = null;
/**
 * Revisions for widget embeddings.
 * @param {import('./plugin').TinyMCE} editor
 * @returns {Record<string, {id: number, lastmodified: number}>} - The available list of widgets indexed by key
 */
export const getEmbedRevs = (editor) => {
    if (!_embedRevs) {
        _embedRevs = Object.create(null);
        Object.entries(getWidgetDict(editor)).forEach(([key, widget]) => {
            // @ts-ignore
            _embedRevs[key] = { id: widget.id, lastmodified: widget.prop('timemodified') };
        });
    }
    return /** @type {Record<string, {id: number, lastmodified: number}>} */ (_embedRevs);
};

export class EditorOptions {
    /**
     * @param {import('./plugin').TinyMCE} editor
     */
    constructor(editor) {
        this.editor = editor;
    }

    /**
     * @returns {{id: number, username: string, roles: string[]}} - an integer with the id of the current user
     */
    get userInfo() {
        return this.editor.options.get(userInfoOptName);
    }

    /**
     * @returns {number} - an integer with the id of the current course
     */
    get courseId() {
        return parseInt(this.editor.options.get(courseIdOptName));
    }

    /**
     * @returns {Object.<string, Widget>} - a dictionary of "usable" widgets for the current userId
     */
    get widgetDict() {
        return getWidgetDict(this.editor);
    }
}

/**
 * @typedef {object} Shared
 * @property {string} currentScope
 */
export const Shared = {
    // In which type of activity the editor is being used
    currentScope: document.querySelector('body')?.id ?? '',
};

/**
 * Add missing properties in the param definition
 * that can be derived from existing data.
 * @param {Param} param
 */
export function fixMissingParamProperties(param) {
    if (!param.type) {
        if (param.options) {
            param.type = 'select';
        } else if (typeof param.value === "boolean") {
            // Infer type from value
            param.type = 'checkbox';
        } else if (typeof param.value === "number") {
            param.type = 'numeric';
        } else if (typeof param.value === "string") {
            param.type = param.options ? 'select' : 'textfield';
        }
    }
    if (!param.value) {
        switch (param.type) {
            case ('checkbox'):
                param.value = false; break;
            case ('numeric'):
                param.value = 0; break;
            case ('select'):
            case ('autocomplete'):
                param.value = param.options?.[0];
                if (typeof (param.value) === 'object') {
                    param.value = param.value.v;
                }
                break;
            case ('color'):
                param.value = '#ffffff'; break;
            default:
                param.value = '';
        }
    }
}

/**
 * @param {*} obj - The object to expand
 * @param {Record<string, *>} partials - The dictionary with partials
 * @returns {*} The modified object
 */
export function expandPartial(obj, partials) {
    if ((obj ?? null) === null) {
        return obj;
    }
    let partialKey;
    if (typeof obj === 'string' && obj.startsWith('__') && obj.endsWith('__')) {
        partialKey = obj;
        obj = {};
    } else if (typeof obj === 'object' && obj.partial) {
        partialKey = obj.partial;
        delete obj.partial;
    }
    if (partialKey) {
        partialKey = partialKey.replace(/__/g, '');
        if (!partials[partialKey]) {
            console.error(`Cannot find partial for ${partialKey}`);
        } else {
            // Override with passed properties.
            obj = { ...partials[partialKey], ...obj };
        }
    }
    return obj;
}

/**
 * Partials are variables that start with @ which
 * can be expanded in different parts of the widget
 * definition.
 * @param {RawWidget} widget
 * @param {Record<string, *>} partials
 * @returns {void} The same widget with partials expanded
 */
export function applyPartials(widget, partials) {
    // Expand partials in template.
    const regex = /__([\w\d]+)__/g;
    if (widget.template) {
        widget.template = widget.template.replace(regex, (s0, s1) => {
            return partials[s1] ?? s0;
        });
    }

    // Expand partials in parameters.
    const parameters = widget.parameters;
    if (parameters) {
        parameters.forEach((/** @type {*} */ param, i) => {
            param = expandPartial(param, partials);
            parameters[i] = param;
            // Treat inner partials
            let prop = expandPartial(param.bind, partials);
            if (prop) {
                param.bind = prop;
            }
            prop = expandPartial(param.transform, partials);
            if (prop) {
                param.transform = prop;
            }
            // Do some fixes on parameters.
            fixMissingParamProperties(param);
        });
    }
}

/**
 * @typedef {Object} ParamOption
 * @property {string} l
 * @property {string} v
 * @property {{to: string, content: string}} [a]
 */
/**
 * @typedef {Object} Param
 * @property {string=} partial
 * @property {string} name
 * @property {string} title
 * @property {'textfield' | 'numeric' | 'checkbox' | 'select' | 'autocomplete' | 'textarea' | 'image' | 'color' | 'repeatable'} [type]
 * @property {(ParamOption | string)[]} [options]
 * @property {any} value
 * @property {string=} tip
 * @property {string=} tooltip
 * @property {number=} min
 * @property {number=} max
 * @property {string=} transform
 * @property {string | {get?: string, set?: string, getValue?: string, setValue?: string} } [bind]
 * @property {string} [item_selector]
 * @property {string=} when
 * @property {boolean} [hidden]
 * @property {boolean} [editable]
 * @property {string} [for]
 * @property {Param[]} [fields]
 */
/**
 * @typedef {Object} Action
 * @property {string} [predicate] - If predicate is absent, it assumes the element holding the widget root.
 * @property {string} actions - Space separated and use | to define a separator.
 * @property {string} [description] - Description is used in context menus and context toolbars.
 */
/**
 * @typedef {Object} RequiresSpec
 * @property {string} url - The url of the js dependency to be included along with the widget.
 * @property {string} [query] - Condition in the form of a css query relative to editor's body. The dependency will be added if the query returns some node.
 */
/**
 * @typedef {Object} RawWidget
 * @property {string} [plugin_release]
 * @property {number} [id]
 * @property {string} key
 * @property {string} name
 * @property {string} category
 * @property {boolean} isfilter - Computed
 * @property {boolean} isselectcapable - Computed
 * @property {boolean} hasbindings - Computed
 * @property {string} [scope] - Regex for identifying allowed body ids
 * @property {string} [instructions]
 * @property {'mustache' | 'ejs'} [engine]
 * @property {string} [template]
 * @property {string} [filter]
 * @property {Param[]} [parameters]
 * @property {Object.<string, Object<string, string>>} [I18n]
 * @property {string | string[]} [selectors]
 * @property {string} [insertquery]
 * @property {string} [unwrap]
 * @property {string} [for]
 * @property {string} [forids] - Equivalent to [for]
 * @property {string} [forusernames]
 * @property {string} [forroles]
 * @property {string} [formatch] - AND or OR, Defaults to AND (The rules must be satified if present)
 * @property {string} [autocomplete]
 * @property {string} version
 * @property {string} author
 * @property {string | RequiresSpec} [requires]
 * @property {boolean} [hidden]
 * @property {number} [stars]
 * @property {Action[]} [contextmenu]
 * @property {Action[]} [contexttoolbar]
 * @property {number} [lastmodified]
 */
/**
 * @class
 * @classdesc Wrapper for Widget definition
 */
export class Widget {
    static documentBatcher = new FetchDocumentsBatch(300);
    _widget;
    #instructionsParsed = false;
    /** @type {string | undefined} */
    _preview;
    /** @type {boolean} */
    _fullyLoaded;
    /** @type {Promise<void> | null} */
    _loadingPromise = null;

    /**
     * @param {RawWidget} widget
     * @param {Object.<string, any>} [partials]
     */
    constructor(widget, partials) {
        if (!widget.key) {
            // Define a random key
            widget.key = 'w' + genID();
        }
        this.partials = partials ?? {};
        this._id = widget.id;
        this._widget = widget;
        this._fullyLoaded = !!widget.template || !!widget.filter;
    }
    /**
     * Fully load widget definition via ajax.
     * @param {import('./plugin').TinyMCE} [editor]
     * @returns {Promise<void>}
     */
    async loadDefinition(editor) {
        if (this._fullyLoaded) {
            return Promise.resolve();
        }
        if (this._loadingPromise) {
            return this._loadingPromise;
        }

        // Define and store the new promise
        const doLoad = async () => {
            try {
                editor?.setProgressState(true);
                const doc = await Widget.documentBatcher.fetchDocument(this._widget.id ?? 0);

                this._widget = {
                    ...this._widget,
                    ...JSON.parse(doc)
                };

                applyPartials(this._widget, this.partials);
                this._fullyLoaded = true;
            } catch (e) {
                console.error(e);
                throw e;
            } finally {
                // Clear the lock so subsequent calls (if failed) can try again
                this._loadingPromise = null;
                editor?.setProgressState(false);
            }
        };

        this._loadingPromise = doLoad();
        return this._loadingPromise;
    }

    /**
     * @returns {number}
     */
    get id() {
        return this._id ?? 0;
    }
    /**
     * @returns {string}
     */
    get name() {
        return this._widget.name;
    }
    /**
     * @returns {string}
     */
    get key() {
        return this._widget.key;
    }
    /**
     * @returns {Record<string, Record<string, string>>}
     */
    get I18n() {
        return this._widget.I18n || {};
    }
    /**
     * @returns {string}
     */
    get template() {
        return this._widget.template ?? this._widget.filter ?? '';
    }
    /**
     * @returns {string | undefined}
     */
    get category() {
        return this._widget.category;
    }
    /**
     * @returns {string=}
     */
    get insertquery() {
        return this._widget.insertquery;
    }
    /**
     * @returns {string | string[] =}
     */
    get selectors() {
        return this._widget.selectors;
    }
    /**
     * @returns {string=}
     */
    get unwrap() {
        return this._widget.unwrap;
    }
    /**
     * @returns {string}
     */
    get version() {
        return this._widget.version || "1.0.0";
    }
    /**
     * @returns {string}
     */
    get instructions() {
        if (this._widget.instructions && !this.#instructionsParsed) {
            this._widget.instructions = decodeURIComponent(this._widget.instructions);
            this.#instructionsParsed = true;
        }
        return this._widget.instructions ?? '';
    }
    /**
     * @returns {Param[]}
     */
    get parameters() {
        return this._widget.parameters ?? [];
    }
    /**
     * @returns {RequiresSpec | null}
     */
    get requires() {
        if (typeof this._widget.requires === 'object' && this._widget.requires.url) {
            return {
                url: this._widget.requires.url.trim(),
                query: this._widget.requires.query?.trim()
            };
        } else if (typeof this._widget.requires === 'string') {
            const [url, query] = this._widget.requires.split('|').map(e => e.trim());
            return { url, query };
        }
        return null;
    }
    /**
     * @returns {Object.<string, any>}
     */
    get defaults() {
        return this.defaultsWithRepeatable(false);
    }
    /**
     * @param {boolean} [populateRepeatable]
     * @returns {Object.<string, any>}
     */
    defaultsWithRepeatable(populateRepeatable = true) {
        /** @type {Object.<string, any> } */
        const obj = Object.create(null);
        (this._widget.parameters ?? []).forEach((param) => {
            obj[param.name] = createDefaultsForParam(param, populateRepeatable);
        });
        return obj;
    }
    /**
     * @param {{id: number, username: string, roles: string[]}} userInfo
     * @returns {boolean}
     */
    isFor(userInfo) {
        if (this._widget.hidden === true) {
            return false;
        }

        const checkList = (/** @type {any} **/ value, /** @type {string} */ ruleStr) => {
            if (!ruleStr || ruleStr.trim() === '') {
                return null; // Null means "no rule set".
            } else if (ruleStr.trim() === '*') {
                return true;
            }

            let allowMode = ruleStr.trim().startsWith('+') || !ruleStr.trim().startsWith('-');
            const list = ruleStr.replace(/[+\-\s]/g, '').split(',');
            return allowMode ? list.includes(String(value)) : !list.includes(String(value));
        };

        const idRule = this._widget.for || this._widget.forids || '';
        const usernameRule = this._widget.forusernames || '';
        const roleRule = this._widget.forroles || '';

        const results = [];

        const idResult = checkList(userInfo.id, idRule);
        if (idResult !== null) {
            results.push(idResult);
        }

        const usernameResult = checkList(userInfo.username, usernameRule);
        if (usernameResult !== null) {
            results.push(usernameResult);
        }

        const roleResult = roleRule.trim() === '' ?
            null : userInfo.roles.some(role => checkList(role?.toLowerCase(), roleRule?.toLowerCase()));
        if (roleResult !== null) {
            results.push(roleResult);
        }

        const matchMode = (this._widget.formatch || 'AND').toUpperCase();

        // No rules at all? Allow by default
        if (results.length === 0) {
            return true;
        }

        const isAllowed = matchMode === 'OR' ? results.some(Boolean) : results.every(Boolean);

        if (!isAllowed) {
            console.warn(`Widget ${this._widget.key} not allowed for user ${userInfo.id}`);
        }

        return isAllowed;
    }

    /**
     * @returns {boolean}
     */
    isFilter() {
        return this._widget.isfilter;
    }

    /**
     * @param {string=} scope
     * @returns {boolean}
     */
    isUsableInScope(scope) {
        scope = scope ?? Shared.currentScope ?? '';
        const widgetScopes = this._widget.scope;
        if (!scope || !widgetScopes || widgetScopes === "*") {
            return true;
        }
        return new RegExp(widgetScopes).test(scope);
    }
    /**
     * @returns {boolean}
     */
    isSelectCapable() {
        return this._widget.isselectcapable;
    }
    /**
     * Determine if a widget contains bindings
     * @returns {boolean}
     */
    hasBindings() {
        return this._widget.hasbindings;
    }

    /**
     * Recovers the property value named name of the original definition
     * @param {string} name
     * @returns {*}
     */
    prop(name) {
        // @ts-ignore
        return this._widget[name];
    }
}


const editorOptionsInstances = new WeakMap();
/**
 * @param {import('./plugin').TinyMCE} editor
 * @returns {EditorOptions}
 */
export function getEditorOptions(editor) {
    let instance = editorOptionsInstances.get(editor);
    if (!instance) {
        instance = new EditorOptions(editor);
        editorOptionsInstances.set(editor, instance);
    }
    return instance;
}
