# Source: CmsMakeupEngine.js

    import { MakeupEngine } from "./MakeupEngine";
    import { SdkAnalytics } from "./Analytics";
    import { updateMakeupDebounce2 } from "./Debounce";
    import { GlobalEngine } from "./GlobalEngine";
    import { exists } from "./Utility/exists";
    import Screen from "./Utility/Screen";
    import {
        expandProductUsagePatternFetch,
        tryToFetchshadeColorProductId,
    } from "./CmsDataFetching";
    import Color from "./Utility/Color";

    /** @typedef {import("./FeatureTemplate").FeatureTemplate} FeatureTemplate */
    /** @typedef {import("./FeatureTemplate").FinishTemplate} FinishTemplate */
    /** @typedef {import("./FeatureTemplate").BoundingBox} BoundingBox */
    /** @typedef {import("./FeatureTemplate").Effect} Effect */
    /** @typedef {import("./Analytics").EffectDataPayload} EffectDataPayload */

    /**
     * Expanded product usage pattern retrieved from the cms.
     * Additional data not required for rendering has been omitted for simplicity.
     * For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
     * @typedef {Object} ProductUsagePattern
     * @property {number} id - Unique identifier for the usage pattern.
     * @property {number | undefined} product - Unique identifier for the product of this usage pattern.
     * @property {UsagePatternArea[]} usagePatternAreas - Array of usage pattern areas.
     */

    /**
     * Usage pattern area retrieved from the cms.
     * Usage pattern defines what application area to render.
     * Additional data not required for rendering has been omitted for simplicity.
     * For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
     * @typedef {Object} UsagePatternArea
     * @property {string} applicationArea - Name of the application area this should equal one {@link FeatureTemplate#name|FeatureTemplate.name} found in {@link MakeupEngine#GetFeatures|MakeupEngine.GetFeatures}
     * @property {ShadeColor} shadeColor - ShadeColor defined for a specific usage pattern area
     */

    /**
     * Shade color retrieved from the cms.
     * Shade color contains the complete color information for rendering of a specific {@link Effect}
     * Additional data not required for rendering has been omitted for simplicity.
     * For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
     * @typedef {Object} ShadeColor
     * @property {number | undefined} id - Unique identifier of the shade color.
     * @property {Finish} finish - Finish used to enhance the shade color.
     * @property {number} colorOpacity - Opacity level of the color. Range is from [0, 100].<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
     * @property {number} finishOpacity - Opacity level of the finish. Range is from [0, 100].<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
     * @property {string} rendering - Hex string representing the color. The format is "0xffffff".<br/> Can be updated with {@link CmsMakeupEngine#UpdateMakeup|UpdateMakeup}
     */

    /**
     * Finish retrieved from the cms.
     * Contains information for rendering a specific {@link FinishTemplate} from {@link MakeupEngine}
     * Additional data not required for rendering has been omitted for simplicity.
     * For the complete type please refer to the official specification for API endpoints: {@link https://cms.arbelle.ai/docs/v2.0}
     * @typedef {Object} Finish
     * @property {string} name - name of the finish, this value should equal any {@link FinishTemplate#name| finish.name} from {@link MakeupEngine#GetFinishes|MakeupEngine.GetFinishes}
     */

    /**
     * Configuration
     * @typedef {Object} UpdateMakeupConfig
     * @property {BoundingBox} [boundingBox = undefined] - New bounding box to be set, if undefined the previous value is retained
     * @property {number} [analyticsDebounceTime = 300] - Define how long the debounce delay for analytics should be
     */

    /**
     * Configuration required to access a specific product usage pattern.
     * @typedef {Object} FetchProductUsagePatternConfiguration
     * @property {number} id - Unique identifier for the product usage pattern.
     * @property {string} languageCode - The appropriate language code of the specific product usage pattern.
     */

    export class CmsMakeupEngine {
        /**
         * @param {FeatureTemplate[]} features - predefined feature templates repository
         * @param {FinishTemplate[]} finishes - predefined finish templates repository
         * @param {SdkAnalytics?} makeupEngineAnalytics - This engine sends data to analytics endpoint if this variable is present
         */
        constructor(features, finishes, makeupEngineAnalytics) {
            // since i check if instance is here, and this will absolutely run before any member, i'll not check there.
            // it's ok if instance is never invalidated at later point, which it shouldn't be
            exists(GlobalEngine.instance, "Engine is not initialized.");
            this.globalEngine = /** @type {GlobalEngine} */ (GlobalEngine.instance);

            this.features = features;
            this.finishes = finishes;
            this.makeupEngineAnalytics = makeupEngineAnalytics;

            /**
             * One pup is a group of effects. In most cases it's one, but we can have patterns of multiple effects, eg. lipstick ombre = lipstick inner + lipstick outer
             * @type {Map<number, {pup: ProductUsagePattern, effects: Effect[]}>}
             */
            this.effectsRecord = new Map();
        }

        // #region ----- Others -------

        /**
         * Returns a DOM element which displays the frame.
         */
        GetView() {
            return Screen.renderer.domElement;
        }

        /**
         * Registers a callback function which is invoked when rendering of a frame passed to FrameProvider is completed and the frame is rendered.
         * <br/>
         * It is advised to use this function to synchronize passing of new frames to FrameProvider with processing of current frame.
         * @param {VoidFunction} callback - Callback that will be invoked on frame completion
         */
        onFrameRendered(callback) {
            return this.globalEngine.onIterationComplete(callback);
        }

        /**
         * Helper method to chain multiple fetch calls and generate required Expanded ProductUsagePattern for rendering using {@link CmsMakeupEngine#CreateMakeup|CmsMakeupEngine.CreateMakeup} in the background
         *
         * @deprecated This helper method is increasing the library responsibilities and coupling to web service without any apparent benefits.
         *
         * @async
         * @throws {Error}
         * @param {FetchProductUsagePatternConfiguration} config - Configuration required to access a specific product usage pattern.
         * @returns {Promise<ProductUsagePattern>}
         */
        async FetchProductUsagePattern(config) {
            const { id, languageCode } = config;
            const url = this.makeupEngineAnalytics?.cmsURL;
            const accessToken = this.makeupEngineAnalytics?.accessToken;
            if (!url || !accessToken) {
                throw new Error(
                    "Please define cmsURL or accessToken to use FetchProductUsagePattern method.",
                );
            }
            return expandProductUsagePatternFetch(
                {
                    url,
                    accessToken,
                    languageCode,
                },
                id,
            );
        }

        /**
         * @param {UsagePatternArea} upa
         * @returns {{feature: FeatureTemplate, finish: FinishTemplate}}
         */
        #UsagePatternAreaToTemplates(upa) {
            let feature = this.features.find((f) => f.name === upa.applicationArea);
            exists(
                feature,
                "Feature template not found for AplicationArea " +
                upa.applicationArea,
            );

            // This tries to find a fitting element to apply rendering color to effect.
            // If found, it will create a copy of template with mutated color.
            const colorElementIdx = feature.elements.findIndex(
                (e) => e.name === "Color",
            );
            if (colorElementIdx >= 0) {
                /** @type {FeatureTemplate} */
                const copy = JSON.parse(JSON.stringify(feature));

                const colorElement = copy.elements[colorElementIdx];
                colorElement.color = hexToColor(
                    upa.shadeColor.rendering,
                    upa.shadeColor.colorOpacity / 100,
                );

                feature = copy;
            }

            let finish = this.finishes.find(
                (f) => f.name === upa.shadeColor.finish.name,
            );
            exists(
                finish,
                "Finish template not found for " + upa.shadeColor.finish.name,
            );

            // Creates a copy to not dirty base template data
            finish = {
                ...finish,
                transparency: upa.shadeColor.finishOpacity / 100,
            };

            return { feature, finish };
        }

        // #endregion

        // #region ----- Analytics ------

        /**
         *
         * @param {UsagePatternArea} upa
         * @param {Effect} eff
         * @returns {Omit<EffectDataPayload, "session_uuid">}
         */
        #upaAndEffectToSimulationPayload(upa, eff) {
            return {
                application_area_name: upa.applicationArea,
                bounding_box: eff.boundingBox,
                color_opacity: Math.round(upa.shadeColor.colorOpacity),
                effect: eff.id,
                finish_name: upa.shadeColor.finish.name,
                finish_opacity: Math.round(upa.shadeColor.finishOpacity),
                rendering_hex: upa.shadeColor.rendering,
                session_product_interaction: undefined,
                shade_color: upa.shadeColor.id,
            };
        }

        /**
         * This is fire-and-forget function.
         *
         * MODIFIES - It may update `pup` parameter asynchronously at some later point.
         *
         * @param {ProductUsagePattern} pup
         * @param {UpaEffectPair[]} upasWithEffects - usually can be infered from record only, but in case of Update, it's racing
         */
        async #sendSimulated(pup, upasWithEffects) {
            if (!pup.product) {
                await this.#tryToFindProductToBackfillData(pup);
            }

            if (this.makeupEngineAnalytics) {
                const productPayload =
                    pup.product
                        ? {
                            type: /** @type {"PRODUCT_SIMULATED"} */ (
                                "PRODUCT_SIMULATED"
                            ),
                            productId: pup.product,
                        }
                        : null;

                this.makeupEngineAnalytics.sendProductAndEffects(
                    productPayload,
                    upasWithEffects
                        // I leave this filter as a reminder, I'm not including it intentionaly
                        // "being displayed/active" is based on idea that either all constituent effects are displayed,
                        // or none are - and that's what implicitly defines if entire product is displayed.
                        // There should never be a case where it's partially visible - program is likely be in an invalid state.
                        // .filter(([, eff]) => this.globalEngine.IsMakeupDisplayed(eff))
                        .map(({upa, effect}) => ({
                            type: "EFFECT_SIMULATED",
                            payload: this.#upaAndEffectToSimulationPayload(
                                upa,
                                effect,
                            ),
                        })),
                );
            }
        }

        /**
         * This is fire-and-forget function.
         *
         * MODIFIES - It may update `pup` parameter asynchronously at some later point.
         *
         * @param {ProductUsagePattern} pup
         * @param {UpaEffectPair[]} upasWithEffects - usually can be infered from record only, but in case of Update, it's racing
         */
        async #sendDisabled(pup, upasWithEffects) {
            if (!pup.product) {
                await this.#tryToFindProductToBackfillData(pup);
            }

            if (this.makeupEngineAnalytics) {
                const productPayload =
                    pup.product
                        ? {
                            type: /** @type {"PRODUCT_DISABLED"} */ (
                                "PRODUCT_DISABLED"
                            ),
                            productId: pup.product,
                        }
                        : null;

                this.makeupEngineAnalytics.sendProductAndEffects(
                    productPayload,
                    upasWithEffects
                        // I leave this filter as a reminder, I'm not including it intentionaly
                        // "being displayed/active" is based on idea that either all constituent effects are displayed,
                        // or none are - and that's what implicitly defines if entire product is displayed.
                        // There should never be a case where it's partially visible - program is likely be in an invalid state.
                        // .filter(([, eff]) => this.globalEngine.IsMakeupDisplayed(eff))
                        .map(({upa, effect}) => ({
                            type: "EFFECT_DISABLED",
                            payload: this.#upaAndEffectToSimulationPayload(
                                upa,
                                effect,
                            ),
                        })),
                );
            }
        }

        /**
         * MODIFIES parameter
         *
         * Tries to backfill product id data if it's missing or not provided.
         *
         * This is done for https://visagetechnologies.atlassian.net/browse/BEA-6931 2nd AC only.
         *
         * @param {ProductUsagePattern} pup If missing product's id, it will try to be modified.
         * @returns {Promise<void>}
         */
        async #tryToFindProductToBackfillData(pup) {
            const shadeColorId = pup.usagePatternAreas.at(0)?.shadeColor.id;

            // shortcircuit as there is nothing to backfill
            if (!shadeColorId) return undefined;

            try {
                const url = this.makeupEngineAnalytics?.cmsURL;
                const accessToken = this.makeupEngineAnalytics?.accessToken;
                if (!url || !accessToken) {
                    throw new Error(
                        "Please define cmsURL or accessToken to use FetchProductUsagePattern method.",
                    );
                }

                const productId = await tryToFetchshadeColorProductId(
                    {
                        url,
                        accessToken,
                    },
                    shadeColorId
                );

                if (productId) {
                    pup.product = productId;
                }
            } catch {
                // Swallow up error if the data back-fill fails
                // It is best effort, but there is no guarantee or reason it should work reliably in any way
                return Promise.resolve()
            }
        }

        // #endregion

        // #region ----- Effects ------

        /**
         * Checks wether the current product usage pattern is currently displayed
         *
         * @param {ProductUsagePattern} productUsagePattern
         * @returns {boolean}
         */
        IsMakeupDisplayed(productUsagePattern) {
            // It doesn't matter what we receive (as this is user's input and can be anything),
            // We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
            const pupId = productUsagePattern.id;

            if (!this.effectsRecord.has(pupId)) return false;
            const records = this.effectsRecord.get(pupId);

            // If effects not found, it's obviously not displayed
            if (!records) return false;

            // There is a special case that's valid value in the codebase at this point - empty array.
            // It's may be up for discussion if it's displayed if nothing is displayed, on the other hand
            // it's correct that empty set is allways displayed. This may not even be an issue and this is good
            // enough for now at least.
            return records.effects.every((eff) =>
                this.globalEngine.IsMakeupDisplayed(eff),
            );
        }

        /**
         * Create a record inside the engine for rendering a {@link ProductUsagePattern|expanded product usage pattern} retrieved from the CMS.<br/>
         * The expanded pattern can be generated through multiple fetch calls to the api: {@link https://cms.arbelle.ai/docs/v2.0}<br/>
         * To simplify the process use the {@link CmsMakeupEngine#FetchProductUsagePattern|CmsMakeupEngine.FetchProductUsagePattern} helper method.<br/>
         * After the promise is resolved reuse the {@link ProductUsagePattern|expanded product usage pattern} in other methods to:
         * <ol>
         * <li>{@link CmsMakeupEngine#DisplayMakeup| Display} the created effect since created effects start off hidden</li>
         * <li>{@link CmsMakeupEngine#UpdateMakeup| Update} the created effect to change colors, opacities...</li>
         * <li>{@link CmsMakeupEngine#HideMakeup| Hide} the created effect if it shouldn't be used anymore </li>
         * <li>{@link CmsMakeupEngine#RemoveMakeup| Remove} the created effect destroying the record inside releasing memory.</li>
         * </ol>
         *
         * @async
         * @throws {Error}
         * @param {ProductUsagePattern} productUsagePattern The product usage pattern to be rendered
         * @param {BoundingBox} [boundingBox=[0.0, 0.0, 1.0, 1.0]] Bounding box of the rendered Product usage pattern defined as [lowerLeftX, lowerLeftY, upperRightX, upperRightY] in the range [0.0, 1.0]
         * @returns {Promise<void>} - Returns a promise witch upon completion adds a record to {@link CmsMakeupEngine}
         */
        async CreateMakeup(
            productUsagePattern,
            boundingBox = [0.0, 0.0, 1.0, 1.0],
        ) {
            if (this.effectsRecord.has(productUsagePattern.id))
                throw new Error("Product usage pattern with id already exists");

            const effects = [];
            const templates = productUsagePattern.usagePatternAreas.map((upa) =>
                this.#UsagePatternAreaToTemplates(upa),
            );

            for (const { feature, finish } of templates) {
                const effect = await this.globalEngine.CreateMakeup(
                    feature,
                    finish,
                    boundingBox,
                );

                effects.push(effect);
            }

            this.effectsRecord.set(productUsagePattern.id, {
                pup: productUsagePattern,
                effects,
            });
        }

        /**
         * Destroys given {@link ProductUsagePattern}
         *
         * @param {ProductUsagePattern} productUsagePattern
         */
        RemoveMakeup(productUsagePattern) {
            // It doesn't matter what we receive (as this is user's input and can be anything),
            // We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
            const pupId = productUsagePattern.id;

            if (!this.effectsRecord.has(pupId)) return;

            const record = this.effectsRecord.get(pupId);
            if (!record) return; // if effects to update were not found silently do nothing - per prior implementation

            const upasWithEffect = zip(
                record.pup.usagePatternAreas,
                record.effects,
            );

            if (this.IsMakeupDisplayed(record.pup)) {
                this.#sendDisabled(record.pup, upasWithEffect);
            }

            record.effects.forEach((eff) => {
                this.globalEngine.RemoveMakeup(eff);
            });

            this.effectsRecord.delete(pupId);
        }

        /**
         * Displays given {@link ProductUsagePattern} on the {@link CmsMakeupEngine#GetView |View}
         *
         * @param {ProductUsagePattern} productUsagePattern
         */
        DisplayMakeup(productUsagePattern) {
            // It doesn't matter what we receive (as this is user's input and can be anything),
            // We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
            const pupId = productUsagePattern.id;

            const record = this.effectsRecord.get(pupId);
            if (!record) return; // if effects were not found silently do nothing - this is not handled and will break. Now it's silent skip per prior implementatio

            const upasWithEffect = zip(
                record.pup.usagePatternAreas,
                record.effects,
            );

            // Only send if it's not already displayed
            if (!this.IsMakeupDisplayed(record.pup)) {
                this.#sendSimulated(record.pup, upasWithEffect);
            }

            record.effects.forEach((eff) => {
                this.globalEngine.DisplayMakeup(eff);
            });
        }

        /**
         * Hides given {@link ProductUsagePattern} from the {@link CmsMakeupEngine#GetView|View}
         * The pattern is still recorded inside the Engine and can be {@link CmsMakeupEngine#DisplayMakeup|redisplayed} again.
         *
         * @param {ProductUsagePattern} productUsagePattern
         */
        HideMakeup(productUsagePattern) {
            // It doesn't matter what we receive (as this is user's input and can be anything),
            // We use only id from it to lookup the record. I moved it to it's own variable to emphasise the fact.
            const pupId = productUsagePattern.id;

            const record = this.effectsRecord.get(pupId);
            if (!record) return; // if effects were not found silently do nothing - this is not handled and will break. Now it's silent skip per prior implementatio

            const upasWithEffect = zip(
                record.pup.usagePatternAreas,
                record.effects,
            );

            // Only send if it is displayed
            if (this.IsMakeupDisplayed(record.pup)) {
                this.#sendDisabled(record.pup, upasWithEffect);
            }

            record.effects.forEach((eff) => {
                this.globalEngine.DisplayMakeup(eff);
            });
        }

        /**
         * Update created product usage pattern to display different colors or opacities.
         * This method is intended for quick changes in rendering similar to {@link MakeupEngine#UpdateMakeup| MakeupEngine.UpdateMakeup}.
         *
         * @async
         * @param {ProductUsagePattern} productUsagePattern - Product usage pattern to be udpated
         * @param {UpdateMakeupConfig} [updateMakeupConfig = { analyticsDebounceTime: 300, boundingBox: undefined }] - Additional configurationm options
         * @returns {Promise<void>}
         */
        async UpdateMakeup(productUsagePattern, updateMakeupConfig = {}) {
            if (!this.effectsRecord.has(productUsagePattern.id)) return;

            const boundingBox = updateMakeupConfig?.boundingBox;
            const analyticsDebounceTime =
                updateMakeupConfig?.analyticsDebounceTime ?? 300; // debounce 300ms default

            const record = this.effectsRecord.get(productUsagePattern.id);
            if (!record) return; // if effects to update were not found silently do nothing - per prior implementation

            const effects = record.effects;
            if (!effects?.length) return; // if effects to update were not found silently do nothing - per prior implementation

            // Although not enforced anywhere in the code in any way, there exists a hidden pre-condition
            // The update function may only update a subset of properties in the effect, eg. color.
            // Any other more drastic changes, including having different number of effects, executes in undefined way.
            // Be careful as that means correctness of the program is compromised.

            // Conceptualy it's an error if lenghts are not same
            // It is left like that as per prior implementation - continue, ignore semantic issues & hope for best "strategy"
            // It's reworked to work with minimal lenght zip of 2 lists
            const upasWithEffect = zip(
                productUsagePattern.usagePatternAreas,
                effects,
            );

            for (const {upa, effect} of upasWithEffect) {
                // This loop will modify the effect.
                // There is no recovery to revert in case of an error!

                const colorElement = effect.elements.find(
                    (e) => e.name === "Color",
                );

                if (colorElement) {
                    colorElement.color = hexToColor(
                        upa.shadeColor.rendering,
                        upa.shadeColor.colorOpacity / 100,
                    );
                }

                // This relies on the fact that finish is allways the last element of the effect.
                // Be carefull as that is an incidental behaviour instead of semantic guarantee.
                const finish = effect.elements[effect.elements.length - 1];

                if (finish) {
                    finish.color.a = upa.shadeColor.finishOpacity / 100;
                }

                if (boundingBox) {
                    effect.boundingBox = boundingBox;
                }
            }

            const updates = effects.map((eff) =>
                this.globalEngine.UpdateMakeup(eff),
            );

            // Only (queue) send if it is displayed
            if (this.IsMakeupDisplayed(record.pup)) {
                updateMakeupDebounce2(
                    productUsagePattern.id,
                    () =>
                        this.#sendSimulated(
                            record.pup,
                            upasWithEffect,
                        ),
                    analyticsDebounceTime,
                );
            }

            await Promise.all(updates);
        }

        // #endregion
    }

    /**
     * @param {string} hex Handles "0xRRGGBB" and "0xRGB" format only. "0x" prefix is fixed,
     * R, G, B are hex numbers representing the obvious colors
     * @returns {Array<number>} - an RGB triplet, integers in range 0-255
     */
    function hexToRgb(hex) {
        // Remove the hash if present
        hex = hex.replace(/^0x/, "");

        // If it's a 3-character hex code, expand it to 6 characters
        if (hex.length === 3) {
            hex = hex
                .split("")
                .map(function (char) {
                    return char + char;
                })
                .join("");
        }

        // Convert the hex string to RGB
        const r = parseInt(hex.substring(0, 2), 16);
        const g = parseInt(hex.substring(2, 4), 16);
        const b = parseInt(hex.substring(4, 6), 16);

        // Return the RGB color code
        return [r, g, b];
    }

    /**
     * @param {string} hex Handles "0xRRGGBB" and "0xRGB" format only. "0x" prefix is fixed,
     * R, G, B are hex numbers representing the obvious colors
     * @param {number} a Alpha value. Defaults to 1.0 (fully opaque)
     * @returns {Color} - an RGB triplet, integers in range 0-255
     */
    function hexToColor(hex, a = 1.0) {
        const [r, g, b] = hexToRgb(hex)
            // Triplet is in 0-255 value range but needs to be normalized to 0.0-1.0 for color
            .map((x) => x / 255);
        return new Color(r, g, b, a);
    }

    /**
     * @typedef {Object} UpaEffectPair
     * @property {UsagePatternArea} upa
     * @property {Effect} effect
     */

    /**
     * Pairs elements from two arrays into a sequence of tuples.
     * The length of the returned array is equal to the length of the shorter input array.
     * Non-generic specialised version.
     * @param {UsagePatternArea[]} arr1 - The first array of elements.
     * @param {Effect[]} arr2 - The second array of elements.
     * @returns {UpaEffectPair[]} An array of tuples containing paired elements.
     */
    function zip(arr1, arr2) {
        const minLength = Math.min(arr1.length, arr2.length);
        return arr1.slice(0, minLength).map((val, i) => ({ upa: val, effect: arr2[i] }));
    }