/* *
 *
 *  (c) 2009-2026 Highsoft AS
 *
 *  Integration of this software requires a license.
 *  - For commercial use, see www.highcharts.com/license
 *  - For non-commercial, see www.highcharts.com/license-eula
 *
 *
 *  Authors:
 *  - Sebastian Bochan
 *  - Wojciech Chmiel
 *  - Gøran Slettemark
 *  - Sophie Bremer
 *  - Dawid Draguła
 *
 * */
'use strict';
import { isCell } from '../Layout/Cell.js';
import CallbackRegistry from '../CallbackRegistry.js';
import ConnectorHandler from './ConnectorHandler.js';
import EditableOptions from './EditableOptions.js';
import Sync from './Sync/Sync.js';
import Globals from '../Globals.js';
const { classNamePrefix } = Globals;
import CU from './ComponentUtilities.js';
const { getMargins, getPaddings } = CU;
import DU from '../Utilities.js';
import { addEvent, createElement, diffObjects, fireEvent, getStyle, isArray, isFunction, merge, objectEach, removeEvent } from '../../Shared/Utilities.js';
import { uniqueKey } from '../../Core/Utilities.js';
const { deepClone } = DU;
/* *
 *
 *  Class
 *
 * */
/**
 * Abstract class of component.
 */
class Component {
    /* *
     *
     *  Static Functions
     *
     * */
    /**
     * Creates HTML text element like header or title
     *
     * @param tagName
     * HTML tag name used as wrapper of text like `h2` or `p`.
     *
     * @param elementName
     * Name of element
     *
     * @param textOptions
     * The options for the component
     *
     * @returns
     * HTML object when title is created, otherwise undefined
     *
     * @internal
     */
    static createTextElement(tagName, elementName, textOptions) {
        if (typeof textOptions === 'object') {
            const { className, text, style } = textOptions;
            return createElement(tagName, {
                className: className || `${classNamePrefix}component-${elementName}`,
                textContent: text
            }, style);
        }
        if (typeof textOptions === 'string') {
            return createElement(tagName, {
                className: `${classNamePrefix}component-${elementName}`,
                textContent: textOptions
            }, {});
        }
    }
    /* *
     *
     *  Constructor
     *
     * */
    /**
     * Creates a component in the cell.
     *
     * @param cell
     * Instance of cell, where component is attached.
     *
     * @param options
     * The options for the component.
     */
    constructor(cell, options, board) {
        /**
         * The connector handlers for the component. They are used to handle the
         * connector options and data tables.
         */
        this.connectorHandlers = [];
        /**
         * Registry of callbacks registered on the component. Used in the Highcharts
         * component to keep track of chart events.
         * @internal
         */
        this.callbackRegistry = new CallbackRegistry();
        /**
         * Event listeners tied to the parent cell. Used for rendering/resizing the
         * component on interactions.
         * @internal
         */
        this.cellListeners = [];
        /**
         * Timeouts for calls to `Component.resizeTo()`.
         * @internal
         */
        this.resizeTimeouts = [];
        /**
         * Timeouts for resizing the content. I.e. `chart.setSize()`.
         * @internal
         */
        this.innerResizeTimeouts = [];
        const renderTo = options.renderTo;
        this.board = board || cell?.row?.layout?.board || {};
        this.parentElement =
            cell?.container || document.querySelector('#' + renderTo);
        this.cell = cell;
        this.options = merge(Component.defaultOptions, options);
        this.id = this.options.id && this.options.id.length ?
            this.options.id :
            uniqueKey();
        if (this.options.connector) {
            const connectorOptionsArray = isArray(this.options.connector) ?
                this.options.connector :
                [this.options.connector];
            for (const connectorOptions of connectorOptionsArray) {
                this.connectorHandlers.push(new ConnectorHandler(this, connectorOptions));
            }
        }
        this.editableOptions =
            new EditableOptions(this, options.editableOptionsBindings);
        this.dimensions = {
            width: null,
            height: null
        };
        this.element = createElement('div', {
            className: this.options.className
        }, {}, this.parentElement);
        if (!Number(getStyle(this.element, 'padding'))) {
            // Fix flex problem, because of wrong height in internal elements
            this.element.style.padding = '0.1px';
        }
        this.contentElement = createElement('div', {
            className: `${this.options.className}-content`
        }, {}, this.element, true);
        this.sync = new Sync(this, this.constructor.predefinedSyncConfig);
        this.setupEventListeners();
        if (cell) {
            this.attachCellListeners();
            this.on('update', () => {
                if (isCell(this.cell)) {
                    this.cell.setLoadingState();
                }
            });
            this.on('afterRender', () => {
                if (isCell(this.cell)) {
                    this.cell.setLoadingState(false);
                }
            });
        }
        this.on('tableChanged', () => {
            this.onTableChanged();
        });
    }
    /**
     * Returns the component's options when it is dropped from the sidebar.
     *
     * @param sidebar
     * The sidebar popup.
     */
    getOptionsOnDrop(
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    sidebar) {
        return {};
    }
    /**
     * Returns the first connector of the component if it exists.
     *
     * @internal
     * @deprecated
     */
    getFirstConnector() {
        return this.connectorHandlers[0]?.connector;
    }
    /**
     * Returns the data table connected to the component by the `connectorId`
     * and `dataTableKey`. If both args are undefined, the first data table is
     * returned.
     *
     * @param connectorId
     * The id of the connector.
     *
     * @param dataTableKey
     * The key of the data table within the connector.
     *
     * @returns
     * The data table, or undefined if no matching handler is found.
     */
    getDataTable(connectorId, dataTableKey) {
        for (const handler of this.connectorHandlers) {
            if ((!connectorId ||
                handler.options.id === connectorId) && (!dataTableKey ||
                handler.options.dataTableKey === dataTableKey)) {
                return handler.dataTable;
            }
        }
    }
    /**
     * Setup listeners on cell/other things up the chain
     * @internal
     */
    attachCellListeners() {
        // Remove old listeners
        while (this.cellListeners.length) {
            const destroy = this.cellListeners.pop();
            if (destroy) {
                destroy();
            }
        }
        if (this.cell &&
            isCell(this.cell) &&
            Object.keys(this.cell).length) {
            const board = this.cell.row.layout.board;
            this.cellListeners.push(
            // Listen for resize on dashboard
            addEvent(board, 'cellResize', () => {
                this.resizeTo(this.parentElement);
            }), 
            // Listen for changed parent
            addEvent(this.cell.row, 'cellChange', (e) => {
                const { row } = e;
                if (row && this.cell) {
                    const hasLeftTheRow = row.getCellIndex(this.cell) === void 0;
                    if (hasLeftTheRow) {
                        if (this.cell) {
                            this.setCell(this.cell);
                        }
                    }
                }
            }));
        }
    }
    /**
     * Set a parent cell.
     *
     * @param cell
     * Instance of a cell.
     *
     * @param resize
     * Flag that allow to resize the component.
     *
     * @internal
     */
    setCell(cell, resize = false) {
        this.cell = cell;
        if (cell.container) {
            this.parentElement = cell.container;
        }
        this.attachCellListeners();
        if (resize) {
            this.resizeTo(this.parentElement);
        }
    }
    /**
     * Initializes connector handlers for the component.
     */
    async initConnectors() {
        fireEvent(this, 'setConnectors', {
            connectorHandlers: this.connectorHandlers
        });
        for (const connectorHandler of this.connectorHandlers) {
            await connectorHandler.initConnector();
        }
        fireEvent(this, 'afterSetConnectors', {
            connectorHandlers: this.connectorHandlers
        });
        return this;
    }
    /**
     * Gets height of the component's content.
     *
     * @returns
     * Current height as number.
     *
     * @internal
     */
    getContentHeight() {
        const titleHeight = this.titleElement ?
            this.titleElement.clientHeight + getMargins(this.titleElement).y :
            0;
        const captionHeight = this.captionElement ?
            this.captionElement.clientHeight +
                getMargins(this.captionElement).y :
            0;
        return titleHeight + captionHeight;
    }
    /**
     * Resize the component
     *
     * @param width
     * The width to set the component to.
     * Can be pixels, a percentage string or null.
     * Null will unset the style
     *
     * @param height
     * The height to set the component to.
     * Can be pixels, a percentage string or null.
     * Null will unset the style.
     */
    resize(width, height) {
        if (height) {
            // Get offset for border, padding
            const pad = getPaddings(this.element).y + getMargins(this.element).y;
            this.element.style.height = 'calc(100% - ' + pad + 'px)';
            this.contentElement.style.height =
                'calc(100% - ' + this.getContentHeight() + 'px)';
        }
        else if (height === null) {
            this.dimensions.height = null;
            this.element.style.removeProperty('height');
        }
        fireEvent(this, 'resize', {
            width,
            height
        });
    }
    /**
     * It's a temporary alternative for the `resize` method. It sets the strict
     * pixel height for the component so that the content can be distributed in
     * the right way, without looping the resizers in the content and container.
     *
     * @param width
     * The width to set the component to.
     *
     * @param height
     * The height to set the component to.
     */
    resizeDynamicContent(width, height) {
        const { element } = this;
        if (height) {
            const margins = getMargins(element).y;
            const paddings = getPaddings(element).y;
            if (typeof height === 'string') {
                height = parseFloat(height);
            }
            height = Math.round(height);
            element.style.height = `${height - margins - paddings}px`;
            this.contentElement.style.height = `${element.clientHeight - this.getContentHeight() - paddings}px`;
        }
        else if (height === null) {
            this.dimensions.height = null;
            element.style.removeProperty('height');
        }
        fireEvent(this, 'resize', {
            width,
            height
        });
    }
    /**
     * Adjusts size of component to parent's cell size when animation is done.
     *
     * @param element
     * HTML element that is resized.
     */
    resizeTo(element) {
        while (this.resizeTimeouts.length) {
            const timeout = this.resizeTimeouts.pop();
            if (timeout) {
                cancelAnimationFrame(timeout);
            }
        }
        const timeoutID = requestAnimationFrame(() => {
            const { width, height } = element.getBoundingClientRect();
            const padding = getPaddings(element);
            const margins = getMargins(element);
            this.resize(width - padding.x - margins.x, height - padding.y - margins.y);
        });
        this.resizeTimeouts.push(timeoutID);
    }
    /**
     * Handles updating via options.
     *
     * @param newOptions
     * The options to apply.
     *
     * @param shouldRerender
     * Set to true if the update should rerender the component.
     */
    async update(newOptions, shouldRerender = true) {
        const eventObject = {
            options: newOptions,
            shouldForceRerender: false
        };
        // Update options
        fireEvent(this, 'update', eventObject);
        if (newOptions.connector && Array.isArray(this.options.connector)) {
            this.options.connector = void 0;
        }
        this.options = merge(this.options, newOptions);
        const connectorOptions = (this.options.connector ? (isArray(this.options.connector) ? this.options.connector :
            [this.options.connector]) : []);
        let connectorsHaveChanged = connectorOptions.length !== this.connectorHandlers.length;
        if (!connectorsHaveChanged) {
            for (let i = 0, iEnd = connectorOptions.length; i < iEnd; i++) {
                const oldOptions = this.connectorHandlers[i]?.options;
                const newOptions = connectorOptions[i];
                // Check if the connector id has changed.
                if (oldOptions.id !== newOptions.id) {
                    connectorsHaveChanged = true;
                    break;
                }
                // Check if the data table key has changed.
                if (oldOptions.dataTableKey !== newOptions.dataTableKey) {
                    connectorsHaveChanged = true;
                    break;
                }
                this.connectorHandlers[i].updateOptions(newOptions);
            }
        }
        if (connectorsHaveChanged) {
            for (const connectorHandler of this.connectorHandlers) {
                connectorHandler.destroy();
            }
            this.connectorHandlers.length = 0;
            for (const options of connectorOptions) {
                this.connectorHandlers.push(new ConnectorHandler(this, options));
            }
            await this.initConnectors();
        }
        if (shouldRerender || eventObject.shouldForceRerender) {
            this.render();
        }
    }
    /**
     * Private method which sets up event listeners for the component.
     *
     * @internal
     */
    setupEventListeners() {
        const events = this.options.events;
        if (events) {
            Object.keys(events).forEach((key) => {
                const eventCallback = events[key];
                if (eventCallback) {
                    this.callbackRegistry.addCallback(key, {
                        type: 'component',
                        func: eventCallback
                    });
                }
            });
            objectEach(events, (eventCallback, eventType) => {
                if (isFunction(eventCallback)) {
                    this.on(eventType, eventCallback);
                }
            });
        }
        const resizeObserverCallback = () => {
            this.resizeTo(this.parentElement);
        };
        if (typeof ResizeObserver === 'function') {
            this.resizeObserver = new ResizeObserver(resizeObserverCallback);
            this.resizeObserver.observe(this.element);
        }
        else {
            const unbind = addEvent(window, 'resize', resizeObserverCallback);
            addEvent(this, 'destroy', unbind);
        }
    }
    /**
     * Adds title at the top of component's container.
     *
     * @param titleOptions
     * The options for the title.
     */
    setTitle(titleOptions) {
        const titleElement = this.titleElement, shouldExist = titleOptions &&
            (typeof titleOptions === 'string' || titleOptions.text);
        if (shouldExist) {
            const newTitle = Component.createTextElement('h2', 'title', titleOptions);
            if (newTitle) {
                if (!titleElement) {
                    this.element.insertBefore(newTitle, this.element.firstChild);
                }
                else {
                    titleElement.replaceWith(newTitle);
                }
                this.titleElement = newTitle;
            }
        }
        else {
            if (titleElement) {
                titleElement.remove();
                delete this.titleElement;
                return;
            }
        }
    }
    /**
     * Adds caption at the bottom of component's container.
     *
     * @param captionOptions
     * The options for the caption.
     */
    setCaption(captionOptions) {
        const captionElement = this.captionElement, shouldExist = captionOptions &&
            (typeof captionOptions === 'string' || captionOptions.text);
        if (shouldExist) {
            const newCaption = Component.createTextElement('div', 'caption', captionOptions);
            if (newCaption) {
                if (!captionElement) {
                    this.element.appendChild(newCaption);
                }
                else {
                    captionElement.replaceWith(newCaption);
                }
                this.captionElement = newCaption;
            }
        }
        else {
            if (captionElement) {
                captionElement.remove();
                delete this.captionElement;
                return;
            }
        }
    }
    /**
     * Handles setting things up on initial render.
     *
     * @returns
     * The component for chaining.
     *
     * @internal
     */
    async load() {
        await this.initConnectors();
        this.render();
        return this;
    }
    /**
     * Renders the component.
     *
     * @returns
     * The component for chaining.
     *
     * @internal
     */
    render() {
        this.emit({ type: 'render' });
        this.setTitle(this.options.title);
        this.setCaption(this.options.caption);
        this.resizeTo(this.parentElement);
        return this;
    }
    /**
     * Destroys the component.
     */
    destroy() {
        /**
         * TODO: Should perhaps set an `isActive` flag to false.
         */
        if (this.sync.isSyncing) {
            this.sync.stop();
        }
        while (this.element.firstChild) {
            this.element.firstChild.remove();
        }
        // Call unmount
        fireEvent(this, 'unmount');
        for (const connectorHandler of this.connectorHandlers) {
            connectorHandler.destroy();
        }
        // Used to removed the onTableChanged event.
        removeEvent(this);
        this.element.remove();
    }
    /**
     * Adds an event listener to the component.
     *
     * @param type
     * The type of event to listen for.
     *
     * @param callback
     * The callback to call when the event is triggered.
     *
     * @returns
     * The function to remove the event listener.
     *
     * @internal
     */
    on(type, callback) {
        return addEvent(this, type, callback);
    }
    /** @internal */
    emit(e) {
        if (!e.target) {
            e.target = this;
        }
        fireEvent(this, e.type, e);
    }
    /**
     * Get the component's options.
     * @returns
     * The JSON of component's options.
     *
     * @internal
     *
     */
    getOptions() {
        return diffObjects(this.options, Component.defaultOptions);
    }
    getEditableOptions() {
        const component = this;
        // When refactoring, limit the copied options to the ones that are
        // actually editable to avoid unnecessary memory usage.
        return deepClone(component.options, [
            'dataTable', 'points', 'series', 'data', 'editableOptions'
        ]);
    }
    getEditableOptionValue(propertyPath) {
        const component = this;
        if (!propertyPath) {
            return;
        }
        let result = component.getEditableOptions();
        for (let i = 0, end = propertyPath.length; i < end; i++) {
            if (isArray(result) &&
                propertyPath[0] === 'connector' &&
                result.length > 1) {
                return 'multiple connectors';
            }
            if (!result) {
                return;
            }
            result = result[propertyPath[i]];
            if (result === false &&
                (propertyPath.indexOf('title') >= 0 ||
                    propertyPath.indexOf('subtitle') >= 0 ||
                    propertyPath.indexOf('caption') >= 0)) {
                result = '';
            }
        }
        return result;
    }
}
/* *
 *
 *  Static Properties
 *
 * */
/** @internal */
Component.Sync = Sync;
/**
 * Predefined sync config for component.
 */
Component.predefinedSyncConfig = {
    defaultSyncOptions: {},
    defaultSyncPairs: {}
};
/**
 * Default options of the component.
 */
Component.defaultOptions = {
    className: `${classNamePrefix}component`,
    id: '',
    title: false,
    caption: false,
    sync: Sync.defaultHandlers,
    editableOptions: [{
            name: 'title',
            propertyPath: ['title'],
            type: 'input'
        }, {
            name: 'caption',
            propertyPath: ['caption'],
            type: 'input'
        }]
};
export default Component;
