import { assert } from "../../../core/assert.js";
import { array_push_if_unique } from "../../../core/collection/array/array_push_if_unique.js";
import Signal from "../../../core/events/signal/Signal.js";
import { IllegalStateException } from "../../../core/fsm/exceptions/IllegalStateException.js";
import { clamp01 } from "../../../core/math/clamp01.js";
import { objectKeyByValue } from "../../../core/model/object/objectKeyByValue.js";
import AssetLevel from "./AssetLevel.js";
import { AssetLoadSpec } from "./AssetLoadSpec.js";

/**
 * @enum {number}
 */
const StateKind = {
    INITIAL: 0,
    LOADING: 1,
    RESOLVED: 2,
}

/**
 * Utility for loading multiple assets together.
 *
 * NOTE: A pre-loader is not intended to be re-used. You have one collection of assets to be loaded, use one {@link AssetPreloader} instance for it. For another batch - use a separate instance.
 *
 * @example
 *  const loader = new AssetPreloader();
 *
 *  loader.add("/images/cat.jpg", "image", AssetLevel.CRITICAL );
 *  loader.add("/images/meow.mp3", "sound", AssetLevel.NORMAL );
 *
 *  const assetManager:AssetManager = ...; // Obtain your asset manager
 *
 *  loader.on.progress.add(({global}) => console.log(`loaded ${global.ratio*100}%`));
 *  loader.on.succeeded.add(()=> console.log("preload complete"));
 *
 *  loader.load(assetManager);
 */
export class AssetPreloader {

    /**
     *
     * @return {number}
     */
    get totalAssetCount() {
        return this.#asset_count++;
    }

    /**
     *
     * @type {number}
     */
    #asset_count = 0;

    /**
     *
     * @type {number}
     */
    #asset_count_loaded = 0;
    /**
     *
     * @type {number}
     */
    #asset_count_failed = 0;

    /**
     *
     * @type {AssetManager}
     */
    #asset_manager;

    /**
     *
     * @type {StateKind}
     */
    #state = StateKind.INITIAL;

    /**
     *
     * @return {boolean}
     */
    get isResolved() {
        return this.#state === StateKind.RESOLVED;
    }

    /**
     * @readonly
     * @type {AssetLoadSpec[][]}
     */
    assets = [];

    /**
     * @readonly
     */
    on = {
        /**
         * @type {Signal<AssetLoadSpec,number>}
         */
        added: new Signal(),
        /**
         * Progress event.
         * Note that the numbers are arbitrary and just signify overall progress and not specific quantifies such as bytes or asset counts.
         * @type {Signal<{global:{current:number, total:number, progress: number}}>}
         */
        progress: new Signal(),
        /**
         * Single priority has finished loading
         * @type {Signal}
         */
        levelFinished: new Signal(),
        /**
         * An asset has failed to load
         */
        error: new Signal(),
        /**
         * @type {Signal<{level:number, count:number}[]>}
         */
        started: new Signal(),
        /**
         * Dispatched when all scheduled assets have been successfully loaded (no errors).
         * @type {Signal<number>}
         */
        succeeded: new Signal(),
        /**
         * All assets are processed, even if some have errored out.
         * This is a more permissive version of {@link succeeded} signal.
         * @type {Signal<number,number>}
         */
        resolved: new Signal(),
    };

    constructor() {

        //build batch containers for each level
        for (let l in AssetLevel) {
            if (AssetLevel.hasOwnProperty(l)) {
                const level = AssetLevel[l];
                this.assets[level] = [];
            }
        }
    }

    /**
     *
     * @param {string} uri
     * @param {string} type
     * @param {number} [level] defines priority group
     * @param {number} [priority] defines priority within the group
     * @return {AssetPreloader}
     */
    add(
        uri,
        type,
        level = AssetLevel.OPTIONAL,
        priority = 0
    ) {

        assert.isString(uri, 'uri');
        assert.isString(type, 'type');
        assert.isNumber(level, 'level');
        assert.isNumber(priority, 'priority');

        if (this.#state !== StateKind.INITIAL) {
            throw new IllegalStateException(`Can only .add in initial state. Actual state = ${objectKeyByValue(StateKind, this.#state)}`)
        }

        const assets = this.assets;

        //asset definition
        const def = AssetLoadSpec.fromJSON({
            uri,
            type,
            priority
        });

        let _level = level;

        if (!assets.hasOwnProperty(_level)) {
            //unsupported level was requested, rewrite to optional
            _level = AssetLevel.OPTIONAL;
            console.warn(`Unsupported level(=${level}) was requested for ${JSON.stringify(def)}, defaulting to optional`);
        }

        assets[_level].push(def);

        this.#asset_count++;

        this.on.added.send2(def, _level);

        return this;
    }

    /**
     *
     * @param {{uri:string, type:string, level?:number}[]} list
     */
    addAll(list) {
        assert.isArray(list, 'list');

        for (const el of list) {
            this.add(el.uri, el.type, el.level);
        }
    }


    #try_finish() {
        if (this.#state === StateKind.RESOLVED) {
            // nothing to do
            return;
        }

        const loaded = this.#asset_count_loaded;
        const failed = this.#asset_count_failed;

        const total_processed = loaded + failed;

        if (total_processed < this.#asset_count) {
            // not done yet
            return;
        }

        this.#state = StateKind.RESOLVED;
        // discard reference to asset manager
        this.#asset_manager = undefined;

        if (failed === 0) {
            this.on.succeeded.send1(loaded);
        }

        this.on.resolved.send2(loaded, failed);
    }

    /**
     * submit requests in batches in order of importance
     * @param {AssetLevel|number} level
     */
    #load_level(level) {
        assert.enum(level, AssetLevel, "level");

        // filter out assets of the specified level
        const batch = this.assets[level];

        const batch_size = batch.length;

        if (batch_size === 0) {

            // a batch of 0 elements.
            // dispatch completion event
            this.on.levelFinished.dispatch(level);

            //early exit
            return;
        }

        let batchElementLoadedCount = 0;
        let batchElementFailedCount = 0;

        /**
         *
         * @param {Asset} asset
         */
        const assetLoadSuccess = (asset) => {
            batchElementLoadedCount++;
            this.#asset_count_loaded++;

            let total_ratio = this.#asset_count > 0 ? clamp01(this.#asset_count_loaded / this.#asset_count) : 1;

            const level_ratio = batch_size > 0 ? clamp01(batchElementLoadedCount / batch_size) : 1;

            //dispatch progress
            this.on.progress.send1({
                level: {
                    id: level,
                    current: batchElementLoadedCount,
                    total: batch_size,
                    progress: level_ratio,
                },
                global: {
                    current: this.#asset_count_loaded,
                    total: this.#asset_count,
                    progress: total_ratio
                }
            });

            //monitor completion
            if ((batchElementLoadedCount + batchElementFailedCount) >= batch_size) {
                this.on.levelFinished.dispatch(level);
            }

            this.#try_finish();
        }

        const assetLoadFailed = (e) => {
            this.#asset_count_failed++;
            batchElementFailedCount++;

            this.on.error.dispatch(e);

            this.#try_finish();
        }

        //sort batch by priority
        batch.sort((a, b) => {
            return b.priority - a.priority;
        });

        const am = this.#asset_manager;

        for (const def of batch) {

            try {

                am.get({
                    path: def.uri,
                    type: def.type,
                    callback: assetLoadSuccess,
                    failure: assetLoadFailed
                });

            } catch (e) {

                assetLoadFailed(e);

            }

        }
    }

    /**
     *
     * @param {AssetManager} assetManager
     * @return {this}
     */
    load(assetManager) {
        assert.defined(assetManager, 'assetManager');
        assert.equal(assetManager.isAssetManager, true, 'assetManager.isAssetManager !== true');

        if (this.#state !== StateKind.INITIAL) {
            throw new IllegalStateException(`Expected initial state. Actual state = ${objectKeyByValue(StateKind, this.#state)}`);
        }

        this.#asset_manager = assetManager;

        this.#state = StateKind.LOADING;

        const on = this.on;

        //current level being processed
        const assets = this.assets;

        //dispatch init event
        const initEvent = assets.map((batch, level) => {
            return {
                level: level,
                count: batch.length
            };
        });

        on.started.send1(initEvent);

        /**
         *
         * @type {number[]}
         */
        const levels = [];

        for (let level in assets) {

            const batch = assets[level];

            if (batch === undefined || batch.length === 0) {
                // batch is empty, skip
                continue;
            }

            let level_i = parseInt(level);

            array_push_if_unique(levels, level_i);
        }

        let last_level_cursor = 0;

        const prod = () => {

            if (last_level_cursor < levels.length) {

                // load next

                const level_to_load = levels[last_level_cursor];

                last_level_cursor++;

                this.on.levelFinished.addOne(prod);

                this.#load_level(level_to_load);

            } else {

                // we're done
                this.#try_finish();

            }

        }

        prod();

        return this;
    }
}

/**
 * @deprecated use {@link AssetPreloader} import instead. Renamed in v2.131.7
 * @type {AssetPreloader}
 */
export const Preloader = AssetPreloader;

