// Copyright (c) 2009-2026 SAP SE, All Rights Reserved

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "sap/ushell/components/tiles/utils",
    "sap/ui/core/format/NumberFormat",
    "sap/ushell/Config",
    "sap/ushell/utils/WindowUtils",
    "sap/ui/model/json/JSONModel",
    "sap/m/library",
    "sap/ushell/library",
    "sap/base/Log",
    "sap/base/util/merge",
    "sap/ushell/utils/DynamicTileRequest",
    "sap/ushell/utils",
    "sap/ushell/utils/UrlParsing",
    "sap/ushell/EventHub",
    "sap/ushell/Container",
    "sap/ushell/navigation/NavigationState"
], (
    Controller,
    tileUtils,
    NumberFormat,
    Config,
    WindowUtils,
    JSONModel,
    mobileLibrary,
    ushellLibrary,
    Log,
    merge,
    DynamicTileRequest,
    ushellUtils,
    UrlParsing,
    EventHub,
    Container,
    NavigationState
) => {
    "use strict";

    // shortcut for sap.m.GenericTileScope
    const GenericTileScope = mobileLibrary.GenericTileScope;

    // shortcut for sap.m.DeviationIndicator
    const DeviationIndicator = mobileLibrary.DeviationIndicator;

    // shortcut for sap.m.ValueColor
    const ValueColor = mobileLibrary.ValueColor;

    // shortcut for sap.m.FrameType
    const FrameType = mobileLibrary.FrameType;

    // shortcut for sap.ushell.DisplayFormat
    const DisplayFormat = ushellLibrary.DisplayFormat;

    // shortcut for sap.ushell.AppType
    const AppType = ushellLibrary.AppType;

    const COMPONENT_NAME = "sap.ushell.components.tiles.cdm.applauncherdynamic.DynamicTile";

    const RESET_STRING = "<RESET>";

    const DEFAULT_VALUES = {
        title: "",
        subtitle: "",
        icon: "",
        info: "",
        infoState: ValueColor.Neutral,
        targetURL: "",
        number_value: "...",
        number_unit: "",
        number_factor: "",
        number_state_arrow: DeviationIndicator.None,
        number_value_state: ValueColor.Neutral
    };

    /* global hasher */

    return Controller.extend(COMPONENT_NAME, {
        _aDoables: [],

        // handle to control/cancel browser's setTimeout()
        timer: null,
        // timestamp of the last regular setTimeout started
        iLastTimeoutStart: 0,
        // handle to control/cancel request
        oDataRequest: null,
        // Whether this tile has defined a interval=0 and a request was already sent
        // This prevents tiles to request again when leaving the tab and navigating back
        bShouldNotRefreshDataAfterInit: false,

        /**
         * Calculates the model data, based on the viewData and defaults
         * @param {object} oViewData The viewData provided by the component
         * @returns {object} The formatted model data
         *
         * @private
         */
        _getConfiguration: function (oViewData) {
            const oConfig = {};
            let oParsedHash;

            oConfig.configuration = oViewData.configuration;
            oConfig.properties = oViewData.properties;

            // a special handling for info, as by the configuration we should not get info anymore.
            // nevertheless - it is used by the dynamic-data response. So we must initialize it to be empty string
            // in case it is not supplied.
            oConfig.properties.info = oConfig.properties.info || DEFAULT_VALUES.info;

            oConfig.properties.number_value = oConfig.properties.preview ? 1234 : DEFAULT_VALUES.number_value;
            oConfig.properties.number_value_state = DEFAULT_VALUES.number_value_state;
            oConfig.properties.number_state_arrow = DEFAULT_VALUES.number_state_arrow;
            oConfig.properties.number_factor = DEFAULT_VALUES.number_factor;
            oConfig.properties.number_unit = oConfig.properties.numberUnit || DEFAULT_VALUES.number_unit;

            // adding sap-system
            const sSystem = oConfig.configuration["sap-system"];
            let sTargetURL = oConfig.properties.targetURL;
            if (sTargetURL && sSystem) {
                // when the navigation url is hash we want to make sure system parameter is in the parameters part
                if (UrlParsing.isIntentUrl(sTargetURL)) {
                    oParsedHash = UrlParsing.parseShellHash(sTargetURL);
                    if (!oParsedHash.params) {
                        oParsedHash.params = {};
                    }
                    oParsedHash.params["sap-system"] = sSystem;
                    sTargetURL = `#${UrlParsing.constructShellHash(oParsedHash)}`;
                } else {
                    sTargetURL += `${(sTargetURL.indexOf("?") < 0) ? "?" : "&"
                    }sap-system=${sSystem}`;
                }
                oConfig.properties.targetURL = sTargetURL;
            }

            oConfig.properties.configSizeBehavior = Config.last("/core/home/sizeBehavior");
            oConfig.properties.wrappingType = Config.last("/core/home/wrappingType");

            switch (oConfig.properties.displayFormat) {
                case DisplayFormat.Flat:
                    oConfig.properties.frameType = FrameType.OneByHalf;
                    break;
                case DisplayFormat.FlatWide:
                    oConfig.properties.frameType = FrameType.TwoByHalf;
                    break;
                case DisplayFormat.StandardWide:
                    oConfig.properties.frameType = FrameType.TwoByOne;
                    break;
                default: {
                    oConfig.properties.frameType = FrameType.OneByOne;
                }
            }

            oConfig.originalProperties = merge({}, oViewData.properties);
            return oConfig;
        },

        onInit: function () {
            const oView = this.getView();
            const oModel = new JSONModel();
            const oViewData = oView.getViewData();
            const oViewDataProperties = oViewData.properties;
            oModel.setData(this._getConfiguration(oViewData));
            const bProviderInfoEnabled = Config.last("/core/contentProviders/providerInfo/enabled");
            const bShowProviderInfoInSystemInfo = Config.last("/core/contentProviders/providerInfo/showContentProviderInfoOnVisualizations");

            oModel.setProperty("/properties/showContentProviderInfoInTooltip", bProviderInfoEnabled);
            oModel.setProperty("/properties/showContentProviderInfoOnVisualizations", bProviderInfoEnabled && bShowProviderInfoInSystemInfo);

            if (bProviderInfoEnabled) {
                // listen for changes of the "showContentProviderInfoOnVisualizations" config,
                // as the end user can change it in the settings (if enabled)
                this._aDoables.push(Config.on("/core/contentProviders/providerInfo/showContentProviderInfoOnVisualizations")
                    .do((bShowContentProviderInfoOnVisualizations) => {
                        oModel.setProperty("/properties/showContentProviderInfoOnVisualizations", bShowContentProviderInfoOnVisualizations);
                    })
                );
            }

            // set model, add content
            oView.setModel(oModel);
            // listen for changes of the size behavior, as the end user can change it in the settings (if enabled)
            this._aDoables.push(Config.on("/core/home/sizeBehavior").do((sSizeBehavior) => {
                oModel.setProperty("/properties/configSizeBehavior", sSizeBehavior);
            }));

            if (oViewDataProperties.preview) {
                this.previewHandler(true);
            }

            // Do not retrieve data initially, wait until the visible handler is called
            // otherwise requests may be triggered which are canceled immediately again.
        },

        /**
         * loads data once if not in configuration mode regardless of the timer
         */
        refreshHandler: function () {
            this.loadData(0, true);
        },

        /**
         * Starts the timer if tile got visible
         * Stops the timer if tile got invisible
         * @param {boolean} isVisible Whether the tile is no visible
         */
        visibleHandler: function (isVisible) {
            if (isVisible) {
                if (!this.oDataRequest || this.timer === null) {
                    // tile is visible and data wasn't requested yet or was invisible before
                    this.initRequestInterval();
                }
            } else {
                this.stopRequests();
            }
        },

        /**
         * Sets the scope property to the model according to the value of "editable".
         *
         * @param {boolean} editable Indicating if edit mode should be active.
         */
        editModeHandler: function (editable) {
            const sScope = editable ? GenericTileScope.ActionMore : GenericTileScope.Display;
            this.getView().getModel().setProperty("/properties/scope", sScope);
        },

        /**
         * Sets the sizeBehavior property to the model.
         *
         * @param {sap.m.TileSizeBehavior} sSizeBehavior The sizeBehavior.
         * @since 1.116.0
         */
        sizeBehaviorHandler: function (sSizeBehavior) {
            this.getView().getModel().setProperty("/properties/customSizeBehavior", sSizeBehavior);
        },

        /**
         * If preview is enabled, restores the original values in the model.
         * Sets the preview property to the model
         *
         * @param {boolean} bPreview The preview flag.
         * @since 1.116.0
         */
        previewHandler: function (bPreview) {
            const oView = this.getView();
            const oModel = oView.getModel();

            if (bPreview) {
                const oViewData = oView.getViewData();
                const oPreviewData = merge({}, oViewData, {
                    properties: {
                        preview: true
                    }
                });
                const oPreviewConfiguration = this._getConfiguration(oPreviewData);

                const oPreviewProperties = merge({}, oPreviewConfiguration.properties, {
                    customSizeBehavior: oModel.getProperty("/properties/customSizeBehavior"),
                    configSizeBehavior: oModel.getProperty("/properties/configSizeBehavior")
                });

                oModel.setProperty("/properties", oPreviewProperties);
                return;
            }

            oModel.setProperty("/properties/preview", false);
        },

        /**
         * API called by the Component to update the static and current properties of the tile
         * @param {object} oNewProperties The new properties
         */
        updateVisualPropertiesHandler: function (oNewProperties) {
            // existing properties
            const oCurrentProperties = this.getView().getModel().getProperty("/properties");
            const oOriginalProperties = this.getView().getModel().getProperty("/originalProperties");

            // override relevant property
            if (ushellUtils.isDefined(oNewProperties.title)) {
                oCurrentProperties.title = oNewProperties.title;
                oOriginalProperties.title = oNewProperties.title;
            }

            if (ushellUtils.isDefined(oNewProperties.subtitle)) {
                oCurrentProperties.subtitle = oNewProperties.subtitle;
                oOriginalProperties.subtitle = oNewProperties.subtitle;
            }

            if (ushellUtils.isDefined(oNewProperties.icon)) {
                oCurrentProperties.icon = oNewProperties.icon;
                oOriginalProperties.icon = oNewProperties.icon;
            }

            if (ushellUtils.isDefined(oNewProperties.targetURL)) {
                oCurrentProperties.targetURL = oNewProperties.targetURL;
                oOriginalProperties.targetURL = oNewProperties.targetURL;
            }

            if (ushellUtils.isDefined(oNewProperties.info)) {
                oCurrentProperties.info = oNewProperties.info;
                oOriginalProperties.info = oNewProperties.info;
            }

            this.getView().getModel().refresh();
        },

        /**
         * Aborts all running requests and clears all running timeouts
         * @private
         */
        stopRequests: function () {
            if (this.timer) {
                clearTimeout(this.timer);
                this.timer = null;
            }
            if (this.oDataRequest) {
                this.oDataRequest.abort();
            }
        },

        // trigger to show the configuration UI if the tile is pressed in Admin mode
        onPress: function (oEvent) {
            /**
             * The keydown event handler directly calls the press event of the GenericTile.
             * Therefore we need to check here if a navigation is already running instead of in the VizInstance.
             */
            if (NavigationState.isNavigationRunning()) {
                return;
            }

            if (oEvent.getSource().getScope && oEvent.getSource().getScope() === GenericTileScope.Display) {
                const sTargetURL = this.getView().getModel().getProperty("/properties/targetURL");
                const sTitle = this.getView().getModel().getProperty("/properties/title");
                if (!sTargetURL) {
                    return;
                }
                EventHub.emit("UITracer.trace", {
                    reason: "LaunchApp",
                    source: "Tile",
                    data: {
                        targetUrl: sTargetURL
                    }
                });
                if (sTargetURL[0] === "#") {
                    hasher.setHash(sTargetURL);
                } else {
                    // add theURL to recent activity log
                    const bLogRecentActivity = Config.last("/core/shell/enableRecentActivity") && Config.last("/core/shell/enableRecentActivityLogging");
                    if (bLogRecentActivity) {
                        const oRecentEntry = {
                            title: sTitle,
                            appType: AppType.URL,
                            url: sTargetURL,
                            appId: sTargetURL
                        };
                        Container.getRendererInternal("fiori2").logRecentActivity(oRecentEntry);
                    }

                    WindowUtils.openURL(sTargetURL, "_blank");
                }
            }
        },

        /**
         * Initializes the The interval by calling loadData with the sanitized refreshInterval
         * @private
         */
        initRequestInterval: function () {
            const oModel = this.getView().getModel();
            let iServiceRefreshInterval = oModel.getProperty("/configuration/serviceRefreshInterval");

            // default to refresh interval 0
            if (!iServiceRefreshInterval || iServiceRefreshInterval === "0") {
                iServiceRefreshInterval = 0;
                if (this.oDataRequest) {
                    // Tile is configured to not refresh its data after the initial fetch
                    // Since a request is already saved we can assume this has happened
                    this.bShouldNotRefreshDataAfterInit = true;
                }
            } else if (iServiceRefreshInterval < 10) {
                const sServiceUrl = oModel.getProperty("/configuration/serviceUrl");
                Log.warning(
                    `Refresh Interval ${iServiceRefreshInterval} seconds for service URL ${sServiceUrl
                    } is less than 10 seconds, which is not supported. Increased to 10 seconds automatically.`,
                    null, COMPONENT_NAME
                );

                // interval of 10 seconds is the minimum allowed for cyclic dynamic data fetching
                // (value of 0 means that no timer is used, e.g. no cyclic fetching but only once).
                iServiceRefreshInterval = 10;
            }
            this.loadData(iServiceRefreshInterval, false);
        },

        /**
         * Creates the request object and triggers the loading of the data
         * @param {int} iServiceRefreshInterval The interval between the requests in seconds
         * @param {boolean} bForce Whether the data should be loaded regardless of the interval
         *
         * @private
         */
        loadData: function (iServiceRefreshInterval, bForce) {
            // Tile is configured (interval=0) to only fetch data once and never update
            if (!bForce && this.bShouldNotRefreshDataAfterInit) {
                return;
            }

            const oModel = this.getView().getModel();

            const bPreview = oModel.getProperty("/properties/preview");
            const sUrl = oModel.getProperty("/configuration/serviceUrl");

            if (bPreview) {
                Log.info("Tile in preview mode", COMPONENT_NAME);
                return;
            }

            if (!sUrl) {
                Log.error("No service URL given!", COMPONENT_NAME);
                this._setTileIntoErrorState();
                return;
            }

            // check whether a timer is running and the previous timeout is over already and wait for it if not
            const iDiff = (iServiceRefreshInterval * 1000) - (Date.now() - this.iLastTimeoutStart);
            if (!bForce && !this.timer && iDiff > 0) {
                Log.info(`Started timeout to call ${sUrl} again in ${Math.ceil(iDiff / 1000)} seconds`, null, COMPONENT_NAME);
                this.timer = setTimeout(this.loadData.bind(this, iServiceRefreshInterval, false), ushellUtils.sanitizeTimeoutDelay(iDiff));
                return;
            }

            // keep request until url changes
            if (!this.oDataRequest || this.oDataRequest.sUrl !== sUrl) {
                if (this.oDataRequest) {
                    this.oDataRequest.destroy();
                }

                const sContentProviderId = oModel.getProperty("/properties/contentProviderId");
                const oOptions = {
                    dataSource: oModel.getProperty("/properties/dataSource")
                };

                this.oDataRequest = new DynamicTileRequest(sUrl, this.successHandlerFn.bind(this), this.errorHandlerFn.bind(this), sContentProviderId, oOptions);
            } else if (this.oDataRequest) {
                this.oDataRequest.refresh();
            }

            // Initialize timeout
            if (iServiceRefreshInterval > 0) {
                Log.info(`Started timeout to call ${sUrl} again in ${iServiceRefreshInterval} seconds`, null, COMPONENT_NAME);
                this.iLastTimeoutStart = Date.now();
                this.timer = setTimeout(this.loadData.bind(this, iServiceRefreshInterval, false), ushellUtils.sanitizeTimeoutDelay(iServiceRefreshInterval * 1000));
            }
        },

        successHandlerFn: function (oResult) {
            // fetching a merged configuration which includes overrides from the dynamic data received
            this.updatePropertiesHandler(oResult);
        },

        // error handler
        errorHandlerFn: function (oMessage) {
            let sMessage = oMessage && oMessage.message ? oMessage.message : oMessage;
            const sUrl = this.getView().getModel().getProperty("/configuration/serviceUrl");

            if (oMessage.statusText === "Abort" || oMessage.aborted === true) {
                Log.info(`Data request from service ${sUrl} was aborted`, null, COMPONENT_NAME);
                this.bShouldNotRefreshDataAfterInit = false;
            } else {
                if (oMessage.response) {
                    sMessage += ` - ${oMessage.response.statusCode} ${oMessage.response.statusText}`;
                }

                Log.error(`Failed to update data via service ${sUrl}: ${sMessage}`, null, COMPONENT_NAME);

                this._setTileIntoErrorState();
            }
        },

        _setTileIntoErrorState: function () {
            const oResourceBundle = tileUtils.getResourceBundle();
            // update model
            this.updatePropertiesHandler({
                number: "???",
                info: oResourceBundle.getText("dynamic_data.error")
            });
        },

        _normalizeNumber: function (numValue, maxCharactersInDisplayNumber, numberFactor, iNumberDigits) {
            let number;

            if (isNaN(numValue)) {
                number = numValue;
            } else {
                const oNForm = NumberFormat.getFloatInstance({ maxFractionDigits: iNumberDigits });

                if (!numberFactor) {
                    const absNumValue = Math.abs(numValue);
                    if (absNumValue >= 1000000000) {
                        numberFactor = "B";
                        numValue /= 1000000000;
                    } else if (absNumValue >= 1000000) {
                        numberFactor = "M";
                        numValue /= 1000000;
                    } else if (absNumValue >= 1000) {
                        numberFactor = "K";
                        numValue /= 1000;
                    }
                }
                number = oNForm.format(numValue);
            }

            let displayNumber = number;
            // we have to crop numbers to prevent overflow
            const cLastAllowedChar = displayNumber[maxCharactersInDisplayNumber - 1];
            // if last character is '.' or ',', we need to crop it also
            maxCharactersInDisplayNumber -= (cLastAllowedChar === "." || cLastAllowedChar === ",") ? 1 : 0;
            displayNumber = displayNumber.substring(0, maxCharactersInDisplayNumber);

            return {
                displayNumber: displayNumber,
                numberFactor: numberFactor
            };
        },

        /**
         * Updates the tile properties with the new values from the service
         * The values for each request are kept separately and do not mix
         * In case a value was not set by the request it either defaults to
         * the initial static values or to proper defaults
         *
         * @param {object} oData Response from the the request
         *
         * @private
         */
        updatePropertiesHandler: function (oData) {
            const oOriginalProperties = this.getView().getModel().getProperty("/originalProperties");
            const oCurrentProperties = this.getView().getModel().getProperty("/properties");

            // when providing empty string or null the original (static) value is displayed
            // when excluding the property from the response the original (static) value is displayed
            // when providing the string the value is unset to a proper default

            const aPropertyMappings = [
                { dataField: "title", modelField: "title" },
                { dataField: "subtitle", modelField: "subtitle" },
                { dataField: "icon", modelField: "icon" },
                { dataField: "info", modelField: "info" },
                { dataField: "infoState", modelField: "infoState" },
                { dataField: "targetURL", modelField: "targetURL" },
                { dataField: "stateArrow", modelField: "number_state_arrow" },
                { dataField: "numberState", modelField: "number_value_state" },
                { dataField: "numberUnit", modelField: "number_unit" },
                { dataField: "numberFactor", modelField: "number_factor" }
            ];

            aPropertyMappings.forEach((oMapping) => {
                const vValue = oData[oMapping.dataField];
                const bIsUndefined = !vValue;
                const vDefaultValue = DEFAULT_VALUES[oMapping.modelField];

                if (bIsUndefined) {
                    oCurrentProperties[oMapping.modelField] = oOriginalProperties[oMapping.modelField] || vDefaultValue;
                } else if (this._isResetValue(vValue)) {
                    oCurrentProperties[oMapping.modelField] = vDefaultValue;
                } else { // response defines a value
                    oCurrentProperties[oMapping.modelField] = vValue;
                }
            });

            oCurrentProperties.number_value = !isNaN(oData.number) ? oData.number : DEFAULT_VALUES.number_value;
            oCurrentProperties.number_digits = oData.numberDigits >= 0 ? oData.numberDigits : 4;

            // push target parameters to local array
            const aTargetURLParams = [];
            if (oData.targetParams) {
                aTargetURLParams.push(oData.targetParams);
            }

            // accumulate results field
            if (oData.results) {
                let sCurrentTargetParams;
                let oCurrentNumber;

                let nSum = 0;
                oData.results.forEach((oResult) => {
                    oCurrentNumber = oResult.number || 0;
                    if (typeof oCurrentNumber === "string") {
                        oCurrentNumber = parseFloat(oCurrentNumber);
                    }
                    nSum += oCurrentNumber;
                    sCurrentTargetParams = oResult.targetParams;
                    if (sCurrentTargetParams) {
                        // push target parameters to local array
                        aTargetURLParams.push(sCurrentTargetParams);
                    }
                });
                oCurrentProperties.number_value = nSum;
            }

            // add target URL properties from local array to targetURL in case needed
            if (aTargetURLParams.length > 0) {
                const sConcatSymbol = oCurrentProperties.targetURL.indexOf("?") !== -1 ? "&" : "?";
                oCurrentProperties.targetURL += sConcatSymbol + aTargetURLParams.join("&");
            }

            if (!isNaN(oData.number)) {
                // in case number is string isNaN returns true, but we need either to trim() it as the redundant " "
                // such as in case of "579 " as a value (Bug), parsing it to float causes redundant '.' even where it should not
                if (typeof oData.number === "string") {
                    oData.number = oData.number.trim();
                }

                const bShouldProcessDigits = this._shouldProcessDigits(oData.number, oData.numberDigits);
                const maxCharactersInDisplayNumber = oCurrentProperties.icon ? 4 : 5;

                if (oData.number && oData.number.toString().length >= maxCharactersInDisplayNumber || bShouldProcessDigits) {
                    const oNormalizedNumberData = this._normalizeNumber(oData.number, maxCharactersInDisplayNumber, oData.numberFactor, oData.numberDigits);

                    oCurrentProperties.number_factor = oNormalizedNumberData.numberFactor;
                    oCurrentProperties.number_value = oNormalizedNumberData.displayNumber;
                } else {
                    const oNForm = NumberFormat.getFloatInstance({ maxFractionDigits: maxCharactersInDisplayNumber });

                    oCurrentProperties.number_value = oNForm.format(oData.number);
                }
            }

            oCurrentProperties.configSizeBehavior = Config.last("/core/home/sizeBehavior");

            this.getView().getModel().refresh();
        },

        _shouldProcessDigits: function (sDisplayNumber, iDigitsToDisplay) {
            let nNumberOfDigits;

            sDisplayNumber = typeof (sDisplayNumber) !== "string" ? sDisplayNumber.toString() : sDisplayNumber;
            if (sDisplayNumber.indexOf(".") !== -1) {
                nNumberOfDigits = sDisplayNumber.split(".")[1].length;
                if (nNumberOfDigits > iDigitsToDisplay) {
                    return true;
                }
            }

            return false;
        },

        // Return lean url for the <a> tag of the Generic Tile
        _getLeanUrl: function (targetURL) {
            return WindowUtils.getLeanURL(targetURL);
        },

        /**
         * Formats the to be a valid enum value
         * @param {string} sColor A ValueColor
         * @returns {sap.m.ValueColor} The formatted ValueColor
         *
         * @since 1.108.0
         * @private
         */
        _formatValueColor: function (sColor) {
            // BCP: 1670054463
            if (sColor === "Positive") {
                return ValueColor.Good;
            }
            if (sColor === "Negative") {
                return ValueColor.Error;
            }

            return ValueColor[sColor] || ValueColor.Neutral;
        },

        /**
         * Formats the to be a valid enum value and replaces 'Neutral' with 'None'
         * @param {string} sValueState The ValueColor of the tile
         * @returns {sap.m.ValueColor} The formatted ValueColor
         *
         * @private
         */
        _getValueColor: function (sValueState) {
            const sColor = this._formatValueColor(sValueState);
            if (sColor === ValueColor.Neutral) {
                return ValueColor.None;
            }
            return sColor;
        },

        /**
         * Checks whether a value matches the static reset string
         * @param {*} vValue A value provided as response by the request
         * @returns {boolean} Whether the given value matches the static reset string
         *
         * @since 1.108.0
         * @private
         */
        _isResetValue: function (vValue) {
            if (typeof vValue !== "string") {
                return false;
            }

            return vValue === RESET_STRING;
        },

        // destroy handler stops requests
        onExit: function () {
            if (this.oDataRequest) {
                this.stopRequests();
                this.oDataRequest.destroy();
            }
            this._aDoables.forEach((oDoable) => {
                oDoable.off();
            });
            this._aDoables = [];
        }
    });
}, /* bExport= */ true);
