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, UUIDRegex, unique, pause } 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() {

        const statusReport = {
            main: {
                // high: {},
                medium: {},
                // low: {}
            },
            UI: {
                // high: {},
                medium: {},
                // low: {}
            },
            specs: {                // this object should not be hardcoded
                // high: {},
                medium: {},
                // low: {}
            }
        };

        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) {

                // console.log( 'no deps')

                // no dependencies!
                statusReport[part] = {
                    // high: {
                    //     status: 'ready',
                    //     dependencies: {}
                    // },
                    medium: {
                        status: 'ready',
                        dependencies: {}
                    },
                    // low: {
                    //     status: 'ready',
                    //     dependencies: {}
                    // }
                };
            }
            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) {
            this._allDependencies = this.directDependencies.reduce(
                (arr, dep) => {
                    if (dep.allDependencies) {
                        return arr.concat(dep, ...dep.allDependencies);
                    }
                    else {
                        return arr.concat(dep);
                    }
                },
                []
            ).filter(unique);
        }

        return this._allDependencies;
    }



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

    _directDependencies;

    get directDependencies() {

        if (!this._directDependencies) {

            this._directDependencies = Component.parts
                .map(part =>
                    Object.values(this._dependencies[part]))
                .flat() // flatten out part arrays
                .flat() // flatten out alternative / integral list arrays
                .filter(unique);
        }

        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 {Object} [instructions]
     * @param {ComponentLoader} [instructions.componentLoader=defaultLoader]
     * @param {ComponentPart} [instructions.part = 'main']
     * @param {LoadingQuality} [instructions.quality = 'medium']
     * @param {Boolean} [instructions.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 };