/**
 * Vue 3 widget helper
 */

import { type ComponentInternalInstance, nextTick } from 'vue';
import { type VueConfig, Widget } from '@bryntum/taskboard';

declare global {
    interface Window {
        bryntum: {
            isTestEnv?: boolean;
            vue?: {
                isVueConfig?: (config: any) => boolean;
                handleVueContent?: (widget: Widget, html: VueConfig) => void;
            };
        };
    }
}

interface RenderElement extends HTMLElement {
    vueKey: string;
}

export type Data = Record<string, unknown>;

type SuppressNew<T> = { [K in keyof T]: T[ K ] }

type AnyConstructor<
    Instance extends object = object,
    Static extends object = object
> = (new (...input: any[]) => Instance) & SuppressNew<Static>

type AnyWidget = AnyConstructor<any, any>;

type RenderData = {
    record: any,
    row: any,
    column: any,
    cellElement: RenderElement
}

type VueTeleportConfig = {
    vue: boolean,
    is: string,
    bind?: object
    on?: object
}

export type WidgetData<T extends AnyWidget> = {
    instanceClass: T,
    instanceName: string
    isView?: boolean,
    dataStores?: object,
    projectStores?: boolean,
    configNames: string[]
    propertyConfigNames: string[]
    propertyNames: string[]
    eventNames: string[]
    featureNames?: string[]
}

export type WidgetConfig<T extends AnyWidget> = {
    me?: ComponentInternalInstance;
    widgetData: WidgetData<T>,
    emit: (...args: any[]) => any
    element: HTMLDivElement;
    processCellContent?: Function;
    processEventContent?: Function;
    processWidgetContent?: Function;
    hasFrameworkRenderer?: Function;
    toRaw?: Function
}

export type BryntumConfig = Record<string, any> & {
    vueComponent?: Object,
    listeners: Record<string, Function>,
    features: {},
    processCellContent: Function,
    processEventContent: Function,
    processWidgetContent: Function;
    hasFrameworkRenderer: Function
};

export class WrapperHelper {

    /**
     * Development warning. Showed when environment is set to 'development'
     * @param {String} clsName vue component instance
     * @param {String} msg console message
     */
    static devWarning(clsName: string, msg: string) {
        // @ts-ignore
        if (window.bryntum?.isTestEnv || process.env.NODE_ENV === 'development') {
            console.warn(`Bryntum${clsName}Component development warning!\n${msg}\n` +
                'Please check Vue integration guide: https://bryntum.com/products/grid/docs/guide/Grid/integration/vue/guide'
            );
        }
    }

    static devWarningContainer(clsName: string, containerParam: string) {
        WrapperHelper.devWarning(clsName,
            `Using "${containerParam}" parameter for configuration is not recommended.\n` +
            'Widget is placed automatically inside it\'s container element.\n' +
            `Solution: remove "${containerParam}" parameter from configuration.`
        );
    }

    static devWarningConfigProp(clsName: string, prop: string) {
        WrapperHelper.devWarning(clsName,
            `Using "${prop}" parameter for configuration is not recommended.\n` +
            `Solution: Use separate parameter for each "${prop}" value to enable reactive updates of the API instance`
        );
    }

    static devWarningUpdateProp(clsName: string, prop: string) {
        WrapperHelper.devWarning(clsName,
            `"${prop}" is a static config option for component constructor only. No runtime changes are supported!`
        );
    }

    static camelize(str: string) {
        return str.replace(/-./g, s => (s[1] || '').toUpperCase());
    }

    static capitalize(str: string) {
        return str.charAt(0).toUpperCase() + str.slice(1);
    }

    /**
     * Returns `true` if the provided configuration object is valid for Vue processing.
     * @param {*} config
     * @returns {Boolean}
     */
    static isVueConfig(config: any): boolean {
        return Boolean(config?.vue);
    }

    /**
     * Creates bryntum component config from vue component
     * @param {Object} context
     * @param {Object} context.me vue component instance
     * @param {Object} context.widgetData reference to data
     * @param {Function} context.emit reference to emit
     * @param {Object} context.element HTML element
     * @param {Function} context.processCellContent cell renderer method
     * @param {Function} context.processEventContent event renderer method
     * @param {Function} context.processWidgetContent widget renderer method
     * @param {Function} context.hasFrameworkRenderer checks presence of vue renderer
     * @param {Function} context.toRaw Vue fn to unwrap reactive object
     * @returns {Object} config object
     */
    static createConfig<T extends AnyWidget>({
        me,
        widgetData,
        emit,
        element,
        processCellContent,
        processEventContent,
        processWidgetContent,
        hasFrameworkRenderer,
        toRaw
    }: WidgetConfig<T>) {

        const
            props: Record<string, any> = {},
            vProps                     = me!.vnode!.props! || {};

        for (const key of Object.keys(vProps)) {
            // Convert possible kebab keys to camelCase
            props[this.camelize(key)] = vProps[key];
        }

        const
            {
                instanceClass,
                instanceName
            }                   = widgetData,
            isValidProp         = (propName: string) => props[propName] !== undefined,
            configNames         = (widgetData.configNames || []).filter(isValidProp),
            propertyConfigNames = (widgetData.propertyConfigNames || []).filter(isValidProp),
            propertyNames       = (widgetData.propertyNames || []).filter(isValidProp),
            featureNames        = (widgetData.featureNames || []).filter(isValidProp),
            // Disable event listeners for onProps, which are duplicating functions with preventable result support
            eventNames          = widgetData.eventNames.filter(eventName => !isValidProp(eventName) && !props[`on${this.capitalize(eventName)}`]),
            bryntumConfig       = {
                vueComponent : me,
                listeners    : {},
                features     : {},
                processCellContent,
                processEventContent,
                processWidgetContent,
                hasFrameworkRenderer
            } as BryntumConfig;

        // Setup events listeners
        eventNames.forEach(eventName => bryntumConfig.listeners[eventName] = (params: any) => emit(eventName, params));

        // Setup configs
        [
            ...configNames,
            ...propertyConfigNames,
            ...featureNames
        ].forEach(propName => {
            WrapperHelper.applyPropValue(bryntumConfig, propName, props[propName], true, toRaw);
            if (['features', 'config'].includes(propName)) {
                WrapperHelper.devWarningConfigProp(instanceClass.$name, propName);
            }
        });

        // Add vue wrapper class name
        bryntumConfig.cls = (bryntumConfig.cls || '') + ` b-vue-${instanceClass.$name.toLowerCase()}-container`;

        // Prepare watch arrays
        widgetData.configNames   = configNames;
        widgetData.propertyNames = [
            ...configNames,
            ...propertyNames,
            ...propertyConfigNames,
            ...featureNames
        ];

        // Cleanup unused instance arrays
        widgetData.eventNames          = [];
        widgetData.propertyConfigNames = [];
        widgetData.featureNames        = [];

        // If component has no container specified in config then use adopt to Wrapper's element
        const
            containerParam = [
                'adopt',
                'appendTo',
                'insertAfter',
                'insertBefore'
            ].find((prop: string) => bryntumConfig[prop]);

        if (!containerParam) {
            if (instanceName === 'Button') {
                // Button should always be <a> or <button> inside owner element
                bryntumConfig.appendTo = element;
            }
            else {
                bryntumConfig.adopt = element;
            }
        }
        else {
            WrapperHelper.devWarningContainer(instanceClass.$name, containerParam);
        }
        return bryntumConfig;
    }

    /**
     * Setup store events relay
     * @param {Object} data reference to data
     * @param {Object} instance bryntum widget instance
     */
    static relayStores(data: any, instance: any) {
        const { dataStores, projectStores } = data;

        if (dataStores) {
            Object.keys(dataStores).forEach(storeName => {
                const store = projectStores ? instance.project[storeName] : instance[storeName];
                if (store) {


                    if (store.syncDataOnLoad == null && !store.readUrl && !store.lazyLoad && (!store.crudManager || !store.crudManager.loadUrl)) {
                        store.syncDataOnLoad = true;
                    }



                    // Makes relaying store events configurable but off by default
                    if (instance && instance.relayStoreEvents) {
                        store.relayAll(instance, dataStores[storeName]);
                    }
                }
            });
            delete data.dataStores;
        }
    }

    /**
     * Creates bryntum Widget from vue component
     * @param {Object} config
     * @param {Object} config.me vue component instance
     * @param {Object} config.widgetData reference to data
     * @param {Function} config.emit reference to emit
     * @param {Object} config.element HTML element
     * @param {Function} config.processCellContent cell renderer method
     * @param {Function} config.hasFrameworkRenderer check the presence of vue renderer
     * @param {Function} config.toRaw Vue fn to unwrap reactive object
     * @returns {Object} widget object
     */
    static createWidget<T extends AnyWidget>(config: WidgetConfig<T>): InstanceType<T> {
        const
            { instanceClass } = config.widgetData,
            cfg               = WrapperHelper.createConfig(config);
        return (instanceClass.$name === 'Widget' ? Widget.create(cfg) : new instanceClass(cfg)) as InstanceType<T>;
    }

    /**
     * Applies property value to Bryntum config or instance.
     * @param {Object} configOrInstance target object
     * @param {String} prop property name
     * @param {Object} value value
     * @param {Boolean} [isConfig] config setting mode
     * @param {Function} [toRaw] Vue fn to unwrap reactive object
     */
    static applyPropValue(configOrInstance: any, prop: string, value: any, isConfig = true, toRaw?: Function) {

        if (prop === 'project') {
            // Allow use ProjectModel component as project
            if (value && typeof value === 'object') {
                configOrInstance[prop] = value.instance ? value.instance.value || value.instance : value;
            }
        }
        else if (prop === 'features' && typeof value === 'object') {
            Object.keys(value).forEach(key => WrapperHelper.applyPropValue(configOrInstance, `${key}Feature`, value[key], isConfig, toRaw));
        }
        else if (prop === 'config' && typeof value === 'object') {
            Object.keys(value).forEach(key => WrapperHelper.applyPropValue(configOrInstance, key, value[key], isConfig, toRaw));
        }
        else if (prop === 'columns' && !isConfig) {
            configOrInstance['columns'].data = value;
        }
        else if (prop.endsWith('Feature')) {
            const
                features    = configOrInstance['features'],
                featureName = prop.replace('Feature', '');
            if (isConfig) {
                features[featureName] = value;
            }
            else {
                const feature = features[featureName];
                if (feature) {
                    feature.setConfig(value);
                }
            }
        }
        else {
            configOrInstance[prop] = toRaw ? toRaw(value) : value;
        }
    }

    /**
     * Creates watches for vue component properties
     * @param {Object} me vue component instance
     * @param {Object} instance bryntum widget instance
     * @param {Object} props reference to props
     * @param {Object} data reference to data
     * @param {Function} watcher watch method reference (Accepts: prop and newValue)
     */
    static watchProps<T extends AnyWidget>(
        instance: InstanceType<T>,
        data: WidgetData<T>,
        watcher: (prop: string, callback: (value: any) => void) => void) {
        const
            {
                configNames,
                propertyNames,
                instanceClass
            } = data;

        propertyNames.forEach(prop => watcher(prop, newValue => {
            const value = Array.isArray(newValue) ? newValue.slice() : newValue;
            WrapperHelper.applyPropValue(instance, prop, value, false);

            // Check if property is a config and notify
            if (configNames.includes(prop)) {
                WrapperHelper.devWarningUpdateProp(instanceClass.$name, prop);
            }
        }));

        // Cleanup unused instance arrays
        data.configNames   = [];
        data.propertyNames = [];
    }

    /**
     * Called from Widget.js to add the Vue component to teleports
     * @param {Object} context
     * @param {Widget} context.widget Widget which content is rendered by the Vue component
     * @param {Object} context.html Object containing the Vue component properties and event listeners
     */
    static processWidgetContent({ widget, html }: { widget: Widget; html: VueTeleportConfig }) {
        const
            { is, bind = {}, vue, on = {} }       = html,
            // @ts-ignore
            { vueComponent }                      = this,
            { teleports : { value : teleports } } = vueComponent.provides,
            { contentElement : to, id }           = widget;

        if (vue) {
            const key = `key-${id}`;

            // delete the teleport with the same key if any
            if (teleports.has(key)) {
                teleports.delete(key);
            }

            nextTick(() => {
                teleports.set(key, { bind, on, key, is, to });
            });
        }
    }

    /**
     * Handles the content provided by a Vue component for the widget.
     * @param {Widget} widget Owner widget
     * @param {String} html The HTML content to be processed
     */
    static handleVueContent(widget: Widget, html: VueConfig): void {
        // Called when a Vue component is to provide the widget content
        const
            hasTeleports = (element: any) => Boolean(element.vueComponent?.provides?.teleports),
            parentView   = widget.closest(hasTeleports) as any,
            // Find a view (grid, scheduler, etc.) which has `teleports` defined. It also has `processWidgetContent`
            view         = parentView?.vueComponent ? parentView : Widget.query(hasTeleports);

        view?.processWidgetContent?.({ widget, html });
    }

    /**
     * Event renderer method
     * @param {Object} context
     * @param {*} context.jsx
     * @param {DomConfig} context.domConfig
     * @param {HTMLElement} context.targetElement
     * @param {Boolean} context.isRelease
     * @returns {Boolean}
     */
    static processEventContent({
        // Core passes it as jsx for React wrappers.
        // Let's rename it to a more appropriate name.
        jsx : rendererConfig,
        domConfig,
        targetElement,
        isRelease
    }: {
        jsx: { [key: string]: any },
        domConfig: any;
        isRelease: boolean;
        targetElement: HTMLElement & { vueKey: string }
        scrolling: boolean
    }) {

        let wrap: any   = targetElement,
            returnValue = false;

        // @ts-ignore
        const domConfigData = this.isVertical ? domConfig?.elementData?.renderData : domConfig?.elementData;

        if (WrapperHelper.isVueConfig(rendererConfig) && rendererConfig?.is) {
            if (domConfig?.dataset?.isMilestone) {
                wrap = targetElement.parentElement!.parentElement!.parentElement!;
            }
            else {
                wrap = targetElement.parentElement!.parentElement!;
            }
            const
                // Vertical nests renderData, while horizontal does not
                // @ts-ignore
                wrapData                              = this.isVertical ? wrap.elementData.renderData : wrap.elementData,
                { assignmentRecord }                  = isRelease ? domConfigData : wrapData,
                { eventRecord }                       = wrapData,
                { vueComponent }                      = this as any,
                { teleports : { value : teleports } } = vueComponent.provides,

                // Recurring events are handled a bit differently so get the flag
                isRecurring = eventRecord.isRecurring || eventRecord.isOccurrence,
                key         = `assignment-${assignmentRecord?.id}${isRecurring ? '-' + eventRecord.id : ''}`;

            // Only delete the teleport if it's rendering into this specific targetElement.
            // Prevents issues when elements are reused during scrolling with LockRows feature.
            if (wrap.vueKey && teleports.get(wrap.vueKey)?.to === targetElement) {
                // Only delete if the existing teleport is rendering into this specific targetElement
                teleports.delete(wrap.vueKey);
            }

            const
                bind            = { ...rendererConfig },
                { is, on = {} } = bind;

            delete bind.is;
            delete bind.on;

            nextTick(() => {
                teleports.set(key, {
                    bind,
                    key,
                    is,
                    on,
                    to         : targetElement,
                    generation : assignmentRecord.generation
                });

            });

            wrap.vueKey = key;
            returnValue = true;
        }

        return returnValue;
    }

    /**
     * Cell renderer method
     * @param {Object} context
     * @param {RenderData} context.rendererData
     * @param {*} context.rendererHtml
     * @returns {Boolean}
     */
    static processCellContent({ rendererData, rendererHtml }: { rendererData: RenderData, rendererHtml: any }) {
        const { record, column, cellElement, row } = rendererData;

        // Only run for cells with vue renderers
        if (WrapperHelper.isVueConfig(column.data) && column.data?.renderer) {
            const
                // `this` is a caller component
                { vueComponent }                      = this as any,
                // `teleports` is javascript Map object
                { teleports : { value : teleports } } = vueComponent.provides,
                key                                   = `${column.id}-${record.id}`,
                renderElement                         = column.tree
                    ? cellElement.querySelector('.b-tree-cell-value')
                    : cellElement;

            // If the cell already has a teleport, release it
            if (cellElement.vueKey) {
                teleports.delete(cellElement.vueKey);
            }

            const
                bind            = { ...rendererHtml },
                { is, on = {} } = bind;

            delete bind.is;
            delete bind.on;

            // Create new teleport
            nextTick(() => {
                // Only teleport if the row is still used for the same record. Since this happens async, we might have
                // scrolled over the entire block of rows since the render
                if (row.id === record.id) {
                    teleports.set(key, {
                        bind,
                        key,
                        is,
                        on,
                        to         : renderElement,
                        generation : record.generation
                    });
                }
            });

            // Link cell to the teleport, to be able to release it above
            cellElement.vueKey = key;
        }
    }

    /**
     *
     * @param {Object} context
     * @param {*} context.cellContent Content to be rendered in cell (set by renderer)
     * @param {*} context.column Column being rendered
     * @returns {Boolean} `true` if there is a Vue Renderer in this cell, `false` otherwise
     */
    static hasFrameworkRenderer({ cellContent, column }: { cellContent: any, column: any }) {
        return cellContent && typeof cellContent === 'object' && WrapperHelper.isVueConfig(column?.data);
    }
}

// Expose wrapper methods on window.bryntum
window.bryntum     = window.bryntum || {};
window.bryntum.vue = {
    isVueConfig      : WrapperHelper.isVueConfig,
    handleVueContent : WrapperHelper.handleVueContent
};
