/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { assert, CompressedId64Set, Id64, OrderedId64Iterable } from "@itwin/core-bentley";
import { SubCategoryAppearance } from "@itwin/core-common";
const invalidCategoryIdEntry = new Set();
/** A cache of information about the subcategories contained within an [[IModelConnection]]. It is populated on demand.
 * @internal
 */
export class SubCategoriesCache {
    constructor(imodel) {
        this._byCategoryId = new Map();
        this._appearances = new Map();
        this._imodel = imodel;
    }
    /** Get the Ids of all subcategories belonging to the category with the specified Id, or undefined if no such information is present. */
    getSubCategories(categoryId) { return this._byCategoryId.get(categoryId); }
    /** Get the base appearance of the subcategory with the specified Id, or undefined if no such information is present. */
    getSubCategoryAppearance(subCategoryId) { return this._appearances.get(subCategoryId.toString()); }
    /** Request that the subcategory information for all of the specified categories is loaded.
     * If all such information has already been loaded, returns undefined.
     * Otherwise, dispatches an asynchronous request to load those categories which are not already loaded and returns a cancellable request object
     * containing the corresponding promise and the set of categories still to be loaded.
     */
    load(categoryIds) {
        const missing = this.getMissing(categoryIds);
        if (undefined === missing)
            return undefined;
        const request = new SubCategoriesCache.Request(missing, this._imodel);
        const promise = request.dispatch().then((result) => {
            if (undefined !== result)
                this.processResults(result, missing);
            return !request.wasCanceled;
        });
        return {
            missingCategoryIds: missing,
            promise,
            cancel: () => request.cancel(),
        };
    }
    /** Load all subcategories that come from used spatial categories of the iModel into the cache. */
    async loadAllUsedSpatialSubCategories() {
        try {
            const results = await this._imodel.queryAllUsedSpatialSubCategories();
            if (undefined !== results) {
                this.processResults(results, new Set(), false);
            }
        }
        catch (e) {
            // In case of a truncated response, gracefully handle the error and exit.
        }
    }
    /** Given categoryIds, return which of these are not cached. */
    getMissing(categoryIds) {
        let missing;
        for (const catId of Id64.iterable(categoryIds)) {
            if (undefined === this._byCategoryId.get(catId)) {
                if (undefined === missing)
                    missing = new Set();
                missing.add(catId);
            }
        }
        return missing;
    }
    clear() {
        this._byCategoryId.clear();
        this._appearances.clear();
    }
    onIModelConnectionClose() {
        this.clear();
    }
    static createSubCategoryAppearance(json) {
        let props;
        if ("string" === typeof json && 0 < json.length)
            props = JSON.parse(json);
        return new SubCategoryAppearance(props);
    }
    processResults(result, missing, override = true) {
        for (const row of result) {
            this.add(row.parentId, row.id, SubCategoriesCache.createSubCategoryAppearance(row.appearance), override);
        }
        // Ensure that any category Ids which returned no results (e.g., non-existent category, invalid Id, etc) are still recorded so they are not repeatedly re-requested
        for (const id of missing)
            if (undefined === this._byCategoryId.get(id))
                this._byCategoryId.set(id, invalidCategoryIdEntry);
    }
    /** Exposed strictly for tests.
     * @internal
     */
    add(categoryId, subCategoryId, appearance, override) {
        let set = this._byCategoryId.get(categoryId);
        if (undefined === set)
            this._byCategoryId.set(categoryId, set = new Set());
        set.add(subCategoryId);
        if (override || !this._appearances.has(subCategoryId))
            this._appearances.set(subCategoryId, appearance);
    }
    async getCategoryInfo(inputCategoryIds) {
        // Eliminate duplicates...
        const categoryIds = new Set(typeof inputCategoryIds === "string" ? [inputCategoryIds] : inputCategoryIds);
        const req = this.load(categoryIds);
        if (req)
            await req.promise;
        const map = new Map();
        for (const categoryId of categoryIds) {
            const subCategoryIds = this._byCategoryId.get(categoryId);
            if (!subCategoryIds)
                continue;
            const subCategories = this.mapSubCategoryInfos(categoryId, subCategoryIds);
            map.set(categoryId, { id: categoryId, subCategories });
        }
        return map;
    }
    async getSubCategoryInfo(categoryId, inputSubCategoryIds) {
        // Eliminate duplicates...
        const subCategoryIds = new Set(typeof inputSubCategoryIds === "string" ? [inputSubCategoryIds] : inputSubCategoryIds);
        const req = this.load(categoryId);
        if (req)
            await req.promise;
        return this.mapSubCategoryInfos(categoryId, subCategoryIds);
    }
    mapSubCategoryInfos(categoryId, subCategoryIds) {
        const map = new Map();
        for (const id of subCategoryIds) {
            const appearance = this._appearances.get(id);
            if (appearance)
                map.set(id, { id, categoryId, appearance });
        }
        return map;
    }
}
/** This namespace and the types within it are exported strictly for use in tests.
 * @internal
 */
(function (SubCategoriesCache) {
    class Request {
        get wasCanceled() { return this._canceled || this._imodel.isClosed; }
        constructor(categoryIds, imodel, maxCategoriesPerQuery = 2500) {
            this._categoryIds = [];
            this._result = [];
            this._canceled = false;
            this._curCategoryIdsIndex = 0;
            this._imodel = imodel;
            const catIds = [...categoryIds];
            OrderedId64Iterable.sortArray(catIds); // sort categories, so that given the same set of categoryIds we will always create the same batches.
            while (catIds.length !== 0) {
                const end = (catIds.length > maxCategoriesPerQuery) ? maxCategoriesPerQuery : catIds.length;
                const compressedIds = CompressedId64Set.compressArray(catIds.splice(0, end));
                this._categoryIds.push(compressedIds);
            }
        }
        cancel() { this._canceled = true; }
        async dispatch() {
            if (this.wasCanceled || this._curCategoryIdsIndex >= this._categoryIds.length) // handle case of empty category Id set...
                return undefined;
            try {
                const catIds = this._categoryIds[this._curCategoryIdsIndex];
                const result = await this._imodel.querySubCategories(catIds);
                this._result.push(...result);
                if (this.wasCanceled)
                    return undefined;
            }
            catch {
                // ###TODO: detect cases in which retry is warranted
                // Note that currently, if we succeed in obtaining some pages of results and fail to retrieve another page, we will end up processing the
                // incomplete results. Since we're not retrying, that's the best we can do.
            }
            // Finished with current batch of categoryIds. Dispatch the next batch if one exists.
            if (++this._curCategoryIdsIndex < this._categoryIds.length) {
                if (this.wasCanceled)
                    return undefined;
                else
                    return this.dispatch();
            }
            // Even if we were canceled, we've retrieved all the rows. Might as well process them to prevent another request for some of the same rows from being enqueued.
            return this._result;
        }
    }
    SubCategoriesCache.Request = Request;
    class QueueEntry {
        constructor(categoryIds, func) {
            this.categoryIds = categoryIds;
            this.funcs = [func];
        }
    }
    SubCategoriesCache.QueueEntry = QueueEntry;
    /** A "queue" of SubCategoriesRequests, which consists of between 0 and 2 entries. Each entry specifies the set of category IDs to be loaded and a list of functions to be executed
     * when loading is completed. This is used to enforce ordering of operations upon subcategories despite the need to asynchronously load them. It incidentally also provides an
     * opportunity to reduce the number of backend requests by batching consecutive requests.
     * Chiefly used by [[Viewport]].
     * @internal
     */
    class Queue {
        constructor() {
            this._disposed = false;
        }
        /** Push a request onto the queue. The requested categories will be loaded if necessary, and then
         * the supplied function will be invoked. Any previously-pushed requests are guaranteed to be processed before this one.
         */
        push(cache, categoryIds, func) {
            if (this._disposed)
                return;
            else if (undefined === this._current)
                this.pushCurrent(cache, categoryIds, func);
            else
                this.pushNext(categoryIds, func);
        }
        /** Cancel all requests and empty the queue. */
        dispose() {
            if (undefined !== this._request) {
                assert(undefined !== this._current);
                this._request.cancel();
                this._request = undefined;
            }
            this._current = this._next = undefined;
            this._disposed = true;
        }
        get isEmpty() {
            return undefined === this._current && undefined === this._next;
        }
        pushCurrent(cache, categoryIds, func) {
            assert(undefined === this._next);
            assert(undefined === this._current);
            assert(undefined === this._request);
            this._request = cache.load(categoryIds);
            if (undefined === this._request) {
                // All requested categories are already loaded.
                func();
                return;
            }
            else {
                // We need to load the requested categories before invoking the function.
                this.processCurrent(cache, new QueueEntry(Id64.toIdSet(categoryIds, true), func));
            }
        }
        processCurrent(cache, entry) {
            assert(undefined !== this._request);
            assert(undefined === this._current);
            assert(undefined === this._next);
            this._current = entry;
            this._request.promise.then((completed) => {
                if (this._disposed)
                    return;
                // Invoke all the functions which were awaiting this set of IModelConnection.Categories.
                assert(undefined !== this._current);
                if (completed)
                    for (const func of this._current.funcs)
                        func();
                this._request = undefined;
                this._current = undefined;
                // If we have more requests, process them.
                const next = this._next;
                this._next = undefined;
                if (undefined !== next) {
                    this._request = cache.load(next.categoryIds);
                    if (undefined === this._request) {
                        // All categories loaded.
                        for (const func of next.funcs)
                            func();
                    }
                    else {
                        // We need to load the requested categories before invoking the pending functions.
                        this.processCurrent(cache, next);
                    }
                }
            });
        }
        pushNext(categoryIds, func) {
            assert(undefined !== this._current);
            assert(undefined !== this._request);
            if (undefined === this._next) {
                // We have a request currently in process and none pending.
                // We could potentially determine that this request doesn't require any categories that are not already loaded or being loaded by the current request.
                // But we will find that out (synchronously) when current request completes, unless more requests come in. Probably not worth it.
                this._next = new QueueEntry(Id64.toIdSet(categoryIds, true), func);
            }
            else {
                // We have a request currently in process, and one or more pending. Append this one to the pending.
                this._next.funcs.push(func);
                for (const categoryId of Id64.iterable(categoryIds))
                    this._next.categoryIds.add(categoryId);
            }
        }
    }
    SubCategoriesCache.Queue = Queue;
})(SubCategoriesCache || (SubCategoriesCache = {}));
//# sourceMappingURL=SubCategoriesCache.js.map