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] }));
}