Source: package/component/buildable_component.js

import { Component } from "../../component/component.js";
import { Reporter } from "../../reporter/reporter.js";
import { ComponentLoader } from "../loader/component_loader.js";
import {
    checkPropTypes,
    copyProps,
    pause,
    unique,
    UUIDRegex,
} from "../../lib.js";
import { WrappedImage } from "../image/wrapped_image.js";
// import { MaterialSet } from '../material/material_set.js';

let defaultLoader = null;

/**
 * Component that combines other components, but does not
 * directly need to load data itself
 */

class BuildableComponent extends Component {
    /**
     * @typedef {('integral'|'alternatives')} ArrayParseType
     */

    /**
     * @typedef {Object} ParseInstruction
     * @property {Boolean} [autoLinkDependencies]
     * @property {Object<string,ArrayParseType>} [parse] - Specification of whether setting arrays should be parsed as lists or alternative options. Default is list.
     */

    /**
     * @param {Reporter} reporter
     * @property {UUID} [data.id]
     * @property {string} [data.name]
     * @property {URL} [data.url]
     * @property {WrappedImage} [data.thumbnail]
     * @param {ParseInstruction} [instructions]
     */

    constructor(reporter, settings, instructions = {}) {
        //console.log( settings.blockInstances )

        super(reporter, settings);

        checkPropTypes(
            settings,
            {},
            {
                thumbnail: WrappedImage,
            },
        );

        //console.log( settings.blockInstances )

        this._resetDependencies();

        // subscribe to events from dependencies to
        // automatically trigger status updates and builds

        if (instructions.autoLinkDependencies !== false) {
            // this method is overwritten in Package
            this._autoLink(settings, instructions);
        }

        if (settings.assignable === true) {
            //
        }
    }

    /**
     * for component tree every array specifies an integral list (not alternatives)
     * @param {Object} settings
     * @returns {Object}
     */

    static autogenIntegralInstruction(settings) {
        return {
            parse: Object.entries(settings).reduce(
                (obj, [setting, value]) => {
                    return Array.isArray(value)
                        ? Object.assign(obj, { [setting]: "integral" })
                        : obj;
                },
                {},
            ),
        };
    }

    _autoLink(settings, instructions) {
        this.report({ msg: "Auto-linking" });

        this.removeDependencyListeners();

        this._resetDependencies();

        // fill the _dependencies field with settings that are
        // Component class instances

        for (let [key, val] of Object.entries(settings)) {
            if (this._debug === true) {
                console.log(this.label, `Process settings "${key}" =`, val);
            }

            // which type of dependency are we dealing with?
            // almost always main, of course

            const part = BuildableComponent.partSettings[key] !== undefined
                ? BuildableComponent.partSettings[key]
                : "main";

            // add a reference to every setting that instances Component
            // in the _dependencies object

            if (val instanceof Component) {
                if (this._debug === true) {
                    console.log(
                        this.label,
                        `Simple setting, adding to _dependencies.${part}`,
                    );
                }

                this._dependencies[part][key] = val;
            } else if (Array.isArray(val)) {
                if (this._debug === true) {
                    console.log(this.label, "Setting value is an array");
                }

                // if the setting's value is an array, it must be (explicitly) specified
                // whether the elements are an integral list or alternative values

                if (instructions.parse?.[key] === undefined) {
                    throw new Error(
                        `Unspecified array setting ${this.constructor.name}.${key}`,
                    );
                }

                // if the dependencies are hidden in objects, bring 'em out
                // and only allow Components

                let components = [];

                for (let subVal of val) {
                    // when dealing with a settings array that has a non-Component object as value
                    // it is possible to specify more than one Component. This is an issue when the
                    // array elements are to be interpreted as alterntives, because these would
                    // essentially be small integral lists within the dependencies. Therefore, if
                    // this is a list of alternatives, only ONE Component per element/object
                    // is allowed.

                    if (
                        typeof subVal === "object" && !Array.isArray(subVal) &&
                        !(subVal instanceof Component)
                    ) {
                        if (this._debug === true) {
                            console.log(
                                this.label,
                                "Subvalue",
                                subVal,
                                "looks like an object",
                            );
                        }

                        const inObjComponents = Object.values(subVal).filter((subValVal) =>
                            subValVal instanceof Component
                        );

                        if (this._debug === true) {
                            console.log(
                                this.label,
                                "Found",
                                inObjComponents,
                                "components in subvalue",
                            );
                        }

                        switch (inObjComponents.length) {
                            case 0:
                                break;

                            case 1:
                                if (this._debug === true) {
                                    console.log(
                                        this.label,
                                        "Adding",
                                        inObjComponents[0],
                                        "to ._dependencies",
                                    );
                                }

                                components.push(inObjComponents[0]);
                                break;

                            default:
                                // more than one Component
                                if (instructions.parse[key] === "alternatives") {
                                    throw new Error(
                                        `${this.label}.key setting holds ${inObjComponents.length} Components, but 1 is the maximum`,
                                    );
                                } else {
                                    if (this._debug === true) {
                                        console.log(
                                            this.label,
                                            "Adding",
                                            inObjComponents.length,
                                            "in-object-dependencies to ._dependencies",
                                        );
                                    }

                                    components.push(...inObjComponents);
                                }
                        }
                    } else if (subVal instanceof Component) {
                        if (this._debug === true) {
                            console.log(
                                this.label,
                                "Subvalue looks like Component. Adding",
                                subVal,
                                "to _dependencies",
                            );
                        }

                        components.push(subVal);
                    }
                }

                if (instructions.parse[key] === "integral") {
                    // if the array is to be parsed as an integral list, every element is added
                    // as a separate dependency (which must be loaded for this component to be ready)

                    for (let i = 0; i < components.length; i += 1) {
                        this._dependencies[part][`${key}-${i}`] = components[i];
                    }
                } else if (instructions.parse[key] === "alternatives") {
                    // if the array is to be parsed as a list of alternatives, the array is
                    // added "as is" and only one of the elements needs to be ready for this
                    // component to be ready as well

                    this._dependencies[part][key] = components;
                }
            } else if (typeof val === "object") {
                let i = 0;
                for (let [subKey, subVal] of Object.entries(val)) {
                    i += 1;
                    if (subVal instanceof Component) {
                        this._dependencies[part][`${key}-${i}`] = subVal;
                    }
                }
            }
        }

        // Object.freeze(this._dependencies.main);
        // Object.freeze(this._dependencies.UI);

        // if ( this.name === 'mat1') {
        //     console.warn( this._dependencies )
        // }

        // when a change event is received from a dependency
        // check whether anything should be done

        const component = this;

        for (let part of Component.parts) {
            for (let dep of Object.values(this._dependencies[part]).flat()) {
                if (this._debug) {
                    console.log(this.label, "subscribe status change", dep.label);
                }

                this.autoBuildOnStatusChange(dep);
            }
        }
    }

    async _dependencyStatusChangeHandler(statusChangeData) {
        await this.autoUpdateStatus();

        if (this._debug) {
            console.warn(this.status);
        }
    }

    autoBuildOnStatusChange(dep) {
        dep.on(
            "status-change",
            this._dependencyStatusChangeHandler.bind(this),
            this.id,
        );
    }

    removeDependencyListeners() {
        for (let part of Component.parts) {
            for (let dep of Object.values(this._dependencies[part]).flat()) {
                if (dep.hasListener("status-change", this.id)) {
                    dep.removeListener("status-change", this.id);
                }
            }
        }
    }

    // /**
    //  * When the component has finished its initialization
    //  * @returns {Promise<BuildableComponent>}
    //  */

    // initialization;

    /**
     * Known UI settings (right now only thumbnail)
     * @static
     * @type {Array<string>}
     */

    //static UIPartSettings = ['thumbnail'];

    //static specsPartSettings = ['dimensionsImage'];

    static partSettings = {
        "thumbnail": "UI",
        "dimensionsImage": "specs",
        "*": "main",
    };

    // static setMutuallyDependent(part, componentA, componentB) {
    //     componentA.addDependency(part, componentB);
    //     componentB.addDependency(part, componentA);
    // }

    addExportDependency(key, component) {
        if (!this.dependsDirectlyOn(component)) {
            this._exportOnlyDependencies[key] = component;
        }
    }

    // /**
    //  * Whether the component should automatically build its content
    //  * when the required dependencies have been loaded
    //  * @private
    //  * @type {boolean}
    //  * @default true
    //  */

    // _autoBuild = true;

    // static updateQueue = Promise.resolve();

    // _autoUpdateStatusChain() {
    //     BuildableComponent.updateQueue = BuildableComponent.updateQueue.then(this._autoUpdateStatus.bind(this))
    //     return BuildableComponent.updateQueue;
    // }

    /**
     * Updates the component's status according to the status of
     * its dependencies
     * @method
     */

    async autoUpdateStatus() {
        const dependencyStatus = this.dependencyStatus;

        if (this._debug === true) {
            console.log("_autoUpdateStatus", this.label, dependencyStatus);
        }

        const callStartState = this.status;

        for (let part of Component.parts) {
            for (let quality of Component.qualities) {
                if (this._debug === true) {
                    // console.debug(this.label, 'check', part, quality);
                }

                const initialState = this._status[part][quality].state;

                if (callStartState[part][quality] !== initialState) {
                    // this.report({
                    //     msg: `${part} ${quality} status changed during async auto update process from ${callStartState[part][quality]} to ${initialState}. Skipping update.`,
                    //     level: 'debug'
                    // });
                    continue;
                }

                const intendedState = dependencyStatus[part][quality].status;

                if (initialState === "error") {
                    if (this._debug === true) {
                        console.log(this.label, "Skip (comp in error state)");
                    }

                    continue;
                }

                if (
                    intendedState === "ready" &&
                    initialState !== "ready" &&
                    this._content[part][quality] === null
                ) {
                    // this.report({ msg: `Auto-building ${part} ${quality} quality` })

                    if (this._debug === true) {
                        console.log(
                            this.label,
                            `Auto building: ${initialState}->${intendedState}`,
                        );
                    }

                    let error = null;

                    try {
                        await this._build(
                            part,
                            quality,
                            dependencyStatus[part][quality].dependencies,
                        );
                    } catch (err) {
                        // console.warn('Auto-build error caught', err);
                        error = err;
                    }

                    const postChangeState = this._status[part][quality].state;

                    if (postChangeState !== "ready" || error !== null) {
                        const errorMsg =
                            `${this.label} status should've been "ready" after auto-build but is "${this._status[part][quality].state
                            }" ${error ? error.message : ""}. ${postChangeState !== "error" ? "Going to error state." : ""
                            }`;

                        this.report({ msg: errorMsg, level: "error" });

                        if (postChangeState !== "error") {
                            this._setError(
                                part,
                                quality,
                                error ? error : new Error(errorMsg),
                            );

                            if (this._debug === true) {
                                console.log("Component set to error state", part, quality);
                            }
                            throw error;
                        }

                        continue;
                    }
                } else if (intendedState !== initialState) {
                    let error = null;

                    if (this._debug === true) {
                        console.log(
                            `Auto updating ${part} ${quality}: ${initialState}->${intendedState}`,
                        );
                    }

                    if (intendedState === "error") {
                        this._error[part][quality] = new Error("Error in dependency chain");
                    }

                    try {
                        // it is entirely possible (likely even) that this state will be changed
                        // during this async call. When this happens, an error might be thrown in case
                        // the line below attempts to do an illegal state transition. It wasn't
                        // illegal when initiated, but it became illegal because of another transition
                        // that cam in between.
                        // For this reason, errors about state transitions are suppressed here.

                        await this._status[part][quality].setState(intendedState);
                    } catch (err) {
                        if (err.message.toLowerCase().indexOf("transition") === -1) {
                            error = err;
                        } else {
                            this.report({
                                msg:
                                    `Status update ${initialState}->${intendedState} failed, probably because the state changed intermediately to "${this._status[part][quality].state
                                    }"`,
                                level: "debug",
                            });
                        }
                    }

                    const postChangeState = this._status[part][quality].state;

                    // and so, we only have an issue when the state failed to change at all
                    // or when another error was thrown

                    // note: it might, in fact, happen that the state is the same - it changed it the meantime though
                    // and so, remove that check as well

                    // if (postChangeState === initialState || error !== null) {
                    if (error !== null) {
                        const errorMsg =
                            `Status change ${initialState}->${intendedState} failed. Current status: "${this._status[part][quality].state
                            }".`;

                        this.report({ msg: errorMsg, level: "error" });

                        if (postChangeState !== "error") {
                            this._setError(
                                part,
                                quality,
                                error ? error : new Error(errorMsg),
                            );

                            if (this._debug === true) {
                                console.log(`${this.label} set to error state`, part, quality);
                            }
                        }

                        continue;
                    }
                }
            }
        }

        // console.log( 'end _autoUpdateStatus', this.label )
    }

    // placeholder content would allow building a scene before anything is loaded

    _placeHolderContent = null;

    defaultContent = this._placeHolderContent;

    /** @typedef {(Component|Array<Component>)} ComponentOrComponentArray */

    /**
     * @typedef {Object<string,ComponentOrComponentArray>} DependencyList
     */

    /**
     * @protected
     * @type {Object}
     * @property {DependencyList} main
     * @property {DependencyList} UI
     * @property {DependencyList} specs
     */

    _dependencies;

    _resetDependencies() {
        this._dependencies = {
            main: {},
            UI: {},
            specs: {}, // should not be hardcoded
        };
    }

    /**
     * @type {Object}
     * @property {DependencyList} main
     * @property {DependencyList} UI
     * @property {DependencyList} specs
     */

    get dependencies() {
        return this._dependencies;
    }

    _exportOnlyDependencies = {};

    // _makeStatusObj = () =>
    //     Component.parts.reduce(
    //         (obj, part) =>

    //     )

    get dependencyStatus() {
        let statusReport = {};

        for (let part of Component.parts) {
            statusReport[part] = {};
            for (let quality of Component.qualities) {
                statusReport[part][quality] = {};
            }
        }

        for (let part of Component.parts) {
            // console.log( this._dependencies, part )

            // try {
            //     console.log( Object.entries(this._dependencies[ part ] ))

            // }
            // catch ( err )
            // {
            //     console.log( err )
            //     console.log( this._dependencies, part, this._dependencies[ part ])
            // }

            const dependencyEntries = Object.entries(this._dependencies[part]);
            // .filter( ([ key, dep ]) => {
            //     return dep.exportEntry === 'root' || dep.exportEntry === undefined
            // });

            if (this._debug === true) {
                console.log(part, "dep entries", dependencyEntries, this._dependencies);
            }

            if (dependencyEntries.length === 0) {
                statusReport[part] = {};

                for (let quality of Component.qualities) {
                    statusReport[part][quality] = {
                        status: "ready",
                        dependencies: {},
                    };
                }
                // console.log( 'no deps')
            } else {
                if (this._debug === true) {
                    console.log("deps!", dependencyEntries);
                }

                for (let quality of Component.qualities) {
                    if (this._debug === true) {
                        console.debug("dep quality", part, quality);
                    }

                    const inQualityStateDistribution = {};
                    const relevantDependencyList = {};

                    for (let [setting, dep] of dependencyEntries) {
                        if (Array.isArray(dep)) {
                            // always check the "main" part of the dependency, regardless of the part
                            // of the component the report is for

                            const altDepStates = dep.map((altDep) =>
                                altDep.status.main[quality]
                            );
                            const highestAltStatus =
                                Component.sortStatusArray(altDepStates)[0];

                            relevantDependencyList[setting] =
                                dep[altDepStates.indexOf(highestAltStatus)];

                            inQualityStateDistribution[highestAltStatus] =
                                (inQualityStateDistribution[highestAltStatus] || 0) + 1;
                        } else {
                            if (this._debug === true) {
                                console.debug(dep.label, dep.status);
                            }
                            inQualityStateDistribution[dep.status.main[quality]] =
                                (inQualityStateDistribution[dep.status.main[quality]] || 0) + 1;
                            relevantDependencyList[setting] = dep;
                        }
                    }

                    if (this._debug === true) {
                        console.debug(
                            this.label,
                            inQualityStateDistribution,
                            relevantDependencyList,
                        );
                    }

                    const inQualityStates = Object.keys(inQualityStateDistribution);

                    if (inQualityStates.length === 1) {
                        statusReport[part][quality].status = inQualityStates[0];
                        if (inQualityStates[0] === "ready") {
                            statusReport[part][quality].dependencies = relevantDependencyList;
                        }
                    } else if (inQualityStates.find((s) => s === "error")) {
                        statusReport[part][quality].status = "error";
                    } else if (inQualityStates.find((s) => s === "loading")) {
                        statusReport[part][quality].status = "loading";
                    } else if (
                        inQualityStates.find((s) => s === "ready") ||
                        inQualityStates.find((s) => s === "partially-loaded")
                    ) {
                        statusReport[part][quality].status = "partially-loaded";
                    } else {
                        statusReport[part][quality].status = "not-loaded";
                    }
                }
            }
        }

        return statusReport;
    }

    /**
     * The distinct material variant groups of this components and its dependencies
     * @protected
     * @type {Array<MaterialSet>}
     */

    _applicableMaterialVariantGroups;

    get applicableMaterialVariantGroups() {
        if (!this._applicableMaterialVariantGroups) {
            const mvgs = [];
            for (let dep of this.allDependencies) {
                if (
                    dep.materialVariantGroup &&
                    mvgs.indexOf(dep.materialVariantGroup) === -1
                ) {
                    mvgs.push(dep.materialVariantGroup);
                }
            }
            this._applicableMaterialVariantGroups = mvgs;
        }

        return this._applicableMaterialVariantGroups;
    }

    /**
     * Dependencies that have a variable material (not necessarily directly assignable)
     * @protected
     * @type {Array<Component>}
     */

    _materializableDependencies;

    get materializableDependencies() {
        if (!this._materializableDependencies) {
            this._materializableDependencies = this.allDependencies.filter((dep) =>
                dep.materializable === true
            );
        }
        // console.log(this.label, this.allDependencies.map(dep => dep.label + ' ' + dep.materializable))

        return this._materializableDependencies;
    }

    /**
     * Dependencies that can be assigned a material (does not necessarily affect the component itself)
     * @protected
     * @type {Array<PositionedMesh|PositionedMeshGroup>}
     */

    _assignableDependencies;

    get assignableDependencies() {
        if (!this._assignableDependencies) {
            this._assignableDependencies = this.allDependencies.filter((dep) =>
                dep.assignable === true
            );
        }
        return this._assignableDependencies;
    }

    /**
     * All "lower" components on which this component depends, either directly or indirectly
     * @protected
     * @type {Array<Component>}
     */

    _allDependencies;

    get allDependencies() {
        if (!this._allDependencies) {
            const deps = this.directDependencies.reduce(
                (arr, dep) => {
                    if (dep.allDependencies) {
                        return arr.concat(dep, ...dep.allDependencies);
                    } else {
                        return arr.concat(dep);
                    }
                },
                [],
            ).filter(unique);

            // pkg is frozen on load by project

            if (Object.isFrozen(this)) {
                return deps;
            } else {
                this._allDependencies = deps;
            }
        }

        return this._allDependencies;
    }

    /**
     * All "lower" components on which this component depends directly
     * @protected
     * @type {Array<Component>}
     */

    _directDependencies;

    get directDependencies() {
        if (!this._directDependencies) {
            const deps = Component.parts
                .map((part) => Object.values(this._dependencies[part]))
                .flat() // flatten out part arrays
                .flat() // flatten out alternative / integral list arrays
                .filter(unique);

            // pkg is frozen on load by project
            //
            if (Object.isFrozen(this)) {
                return deps;
            } else {
                this._directDependencies = deps;
            }
        }

        return this._directDependencies;
    }

    /** @returns {Boolean} */

    dependsDirectlyOn(component) {
        return this.directDependencies.includes(component);
    }

    /** @returns {Boolean} */

    dependsOn(component) {
        return this.allDependencies.includes(component);

        // const allDeps = this.directDependencies;

        // return allDeps.includes(component) ||
        //     allDeps.find(dep =>
        //         dep instanceof BuildableComponent ? dep.dependsOn(component) : false
        //     );
    }

    highestMainQuality() {
        const dependencyStatus = this.dependencyStatus;

        if (dependencyStatus.high.status !== "ready") {
            return { quality: "high", dependencies: dependencyStatus.dependencies };
        } else if (dependencyStatus.medium.status !== null) {
            return { quality: "medium", dependencies: dependencyStatus.dependencies };
        } else if (dependencyStatus.low.status !== null) {
            return { quality: "low", dependencies: dependencyStatus.dependencies };
        } else {
            return null;
        }
    }

    /**
     * @async
     * @interface
     * @protected
     * @method
     * @param {LoadingQuality} quality
     * @param {Object<string,Component>} dependencies - Dependencies that are loaded with the corresponding quality
     * @returns {Promise<Component>}
     */

    async _build(part, quality, dependencies) {
        this.report({
            level: "notice",
            msg: "Override BuildableComponent._build interface method!",
        });
        this._setContent(part, quality, "-");
        return this;
    }

    _buildPromises = {
        main: {
            // high: null,
            medium: null,
            // low: null
        },
        UI: {
            // high: null,
            medium: null,
            // low: null
        },
        specs: {
            // high: null,
            medium: null,
            // low: null
        },
    };

    _rebuild(part = "main") {
        for (let quality of Component.qualities) {
            if (this.status[part][quality] === "ready") {
                this._build("main", quality);
            }
        }
    }

    /**
     * @method
     * @param {ComponentLoader} [componentLoader=defaultLoader]
     * @param {ComponentPart} [part = 'main']
     * @param {LoadingQuality} [quality = 'medium']
     * @param {Boolean} [highPriority = false]
     * @returns {Promise<BuildableComponent>}
     */

    async build(
        {
            componentLoader,
            part = "main",
            quality = "medium",
            highPriority = false,
        } = {},
    ) {
        if (this._buildPromises[part][quality]) {
            // console.log(this.label, part, 'Already loaded/loading, returning old promise' )
            // this.report({ msg: 'Already loaded/loading, returning old promise', level: 'debug' })
            return this._buildPromises[part][quality];
        }

        // if ( part === 'specs' && ! Object.keys( this._settings ).find( setting => BuildableComponent.specsPartSettings.includes(setting))) {
        //     this._setContent(part,quality,null);
        // }

        let loader = componentLoader;

        if (loader) {
            if (!(loader instanceof ComponentLoader)) {
                throw new Error("Loader does not instance class ComponentLoader");
            } else {
                this.report({ msg: "Using specified loader" });
            }
        } else if (this._tree && this._tree.loader) {
            this.report({ msg: "Using component loader from component tree" });
            loader = this._tree.loader;
        } else {
            this.report({ msg: `No loader specified, using default loader` });

            if (!defaultLoader) {
                this.report({ msg: `Creating default loader` });
                console.log(`Creating default loader`);
                defaultLoader = new ComponentLoader(this._reporter, {
                    name: "Default loader",
                });
            }

            loader = defaultLoader;
        }

        this._buildPromises[part][quality] = loader.build(
            this,
            part,
            quality,
            highPriority,
        );

        //console.log( this._buildPromises[part][quality] )

        return this._buildPromises[part][quality];
    }

    destroy() {
        this.removeDependencyListeners();
        if (super.destroy) {
            super.destroy();
        }
    }
}

export { BuildableComponent };