/*!
 * Copyright (c) 2026 Vertex Software LLC. All rights reserved.
 */
import { grpc } from "@improbable-eng/grpc-web";
import { OffsetPager } from "@vertexvis/scene-tree-protos/core/protos/paging_pb";
import { Uuid } from "@vertexvis/scene-tree-protos/core/protos/uuid_pb";
import { IndexingStatus, } from "@vertexvis/scene-tree-protos/scenetree/protos/domain_pb";
import { CollapseAllRequest, CollapseNodeRequest, ExpandAllRequest, ExpandNodeRequest, FilterRequest, GetAvailableColumnsRequest, GetNodeAncestorsRequest, GetTreeRequest, LocateItemRequest, SubscribeRequest, } from "@vertexvis/scene-tree-protos/scenetree/protos/scene_tree_api_pb";
import { EventDispatcher } from "@vertexvis/utils";
import { SceneTreeConnectionCancelledError, SceneTreeError, SceneTreeErrorCode, SceneTreeErrorDetails, SceneTreeOperationFailedError, SceneTreeUnauthorizedError, } from "./errors";
import { isGrpcServiceError } from "./grpc";
import { decodeSceneTreeJwt } from "./jwt";
import { fromNodeProto, isLoadedRow } from "./row";
/**
 * The `SceneTreeController` is responsible for coordinating interactions of the
 * view, fetching and mutating tree data on the server, maintaining state and
 * notifying the view about state changes.
 */
export class SceneTreeController {
    /**
     * The number of pages that have been fetched.
     */
    get fetchedPageCount() {
        return this.pages.size;
    }
    /**
     * Indicates if the controller is connected to the tree backend, and can make
     * requests.
     */
    get isConnected() {
        return this.state.connection.type === 'connected';
    }
    constructor(client, rowLimit, connectOptions = {
        spinnerDelay: 2000,
        subscriptionHandshakeGracePeriodInMs: 5000,
    }) {
        this.client = client;
        this.rowLimit = rowLimit;
        this.connectOptions = connectOptions;
        this.nextPageId = 0;
        this.pages = new Map();
        this.activeRowRange = [0, 0];
        this.metadataKeys = [];
        this.debugLogs = false;
        /**
         * A dispatcher that emits an event whenever the internal state has changed.
         */
        this.onStateChange = new EventDispatcher();
        this.state = {
            totalRows: 0,
            isSearching: false,
            rows: [],
            connection: { type: 'disconnected' },
        };
    }
    /**
     * Registers an event listener that will be invoked when the state changes
     *
     * @param listener The listener to add.
     * @returns A disposable that can be used to remove the listener.
     */
    stateChanged(listener) {
        return this.onStateChange.on(listener);
    }
    setDebugLogs(debugLogs) {
        this.debugLogs = debugLogs;
    }
    async handleSubscriptionHandshakeTimeout(jwtProvider, sceneViewId) {
        var _a;
        const connection = this.getState().connection;
        if (this.isConnectedState(connection) &&
            ((_a = connection.subscriptionStatusState) === null || _a === void 0 ? void 0 : _a.attempt) != null &&
            connection.subscriptionStatusState.attempt <
                SceneTreeController.MAX_SUBSCRIPTION_RETRY_COUNT) {
            const newAttempt = connection.subscriptionStatusState.attempt + 1;
            console.warn(`Failed to subscribe within the allotted timeout. Retry attempt={${newAttempt}}`);
            connection.subscriptionStatusState.stream.cancel();
            this.clearHandshakeTimer();
            const stream = await this.subscribe();
            this.invalidateAfterOffset(0);
            this.fetchUnloadedPagesInActiveRows();
            this.updateState({
                ...this.state,
                connection: {
                    ...connection,
                    type: 'connected',
                    subscription: {
                        dispose: () => stream.cancel(),
                    },
                    subscriptionStatusState: {
                        attempt: newAttempt,
                        stream,
                    },
                },
            });
            this.subscriptionHandshakeTimer = window.setTimeout(() => {
                this.handleSubscriptionHandshakeTimeout(jwtProvider, sceneViewId);
            }, this.connectOptions.subscriptionHandshakeGracePeriodInMs);
        }
        else {
            this.updateState({
                ...this.state,
                connection: {
                    type: 'failure',
                    jwtProvider,
                    sceneViewId,
                    details: new SceneTreeErrorDetails('SUBSCRIPTION_FAILURE', SceneTreeErrorCode.SUBSCRIPTION_FAILURE),
                },
            });
        }
    }
    async connect(jwtProvider) {
        const { connection } = this.state;
        const jwt = jwtProvider();
        if (jwt == null) {
            throw new SceneTreeUnauthorizedError('Cannot connect scene tree. JWT is undefined');
        }
        const { view: sceneViewId } = decodeSceneTreeJwt(jwt);
        if (connection.sceneViewId !== sceneViewId) {
            this.disconnect(true);
        }
        else {
            this.disconnect(false);
        }
        const connecting = {
            type: 'connecting',
            jwtProvider,
            sceneViewId,
        };
        this.updateState({ ...this.state, connection: connecting });
        try {
            this.log('Scene tree controller connecting.');
            this.restartLoadingTimer();
            // Ensure we have a subscription prior to attempting to get the first page
            // to make sure we receive any ListChange that comes through
            const stream = await this.subscribe();
            await this.fetchPage(0);
            if (this.state.connection.type !== 'cancelled') {
                this.updateState({
                    ...this.state,
                    connection: {
                        jwtProvider,
                        sceneViewId,
                        type: 'connected',
                        subscription: { dispose: () => stream.cancel() },
                        subscriptionStatusState: {
                            attempt: 0,
                            stream,
                        },
                    },
                });
                this.restartHandshakeTimer(jwtProvider, sceneViewId);
            }
        }
        catch (e) {
            this.ifErrorIsFatal(e, () => {
                this.updateState({
                    ...this.state,
                    connection: {
                        type: 'failure',
                        jwtProvider,
                        sceneViewId,
                        details: this.getConnectionError(e),
                    },
                });
                this.clearHandshakeTimer();
                throw e;
            });
        }
        this.startIdleReconnectTimer();
    }
    clearReconnectTimer() {
        if (this.reconnectTimer != null) {
            window.clearTimeout(this.reconnectTimer);
        }
    }
    restartHandshakeTimer(jwtProvider, sceneViewId) {
        this.clearHandshakeTimer();
        if (!this.state.handshakeReceived) {
            this.subscriptionHandshakeTimer = window.setTimeout(() => {
                this.handleSubscriptionHandshakeTimeout(jwtProvider, sceneViewId);
                this.subscriptionHandshakeTimer = undefined;
            }, this.connectOptions.subscriptionHandshakeGracePeriodInMs);
        }
    }
    clearHandshakeTimer() {
        if (this.subscriptionHandshakeTimer != null) {
            window.clearTimeout(this.subscriptionHandshakeTimer);
            this.subscriptionHandshakeTimer = undefined;
        }
    }
    startIdleReconnectTimer() {
        this.startReconnectTimer(this.connectOptions.idleReconnectInSeconds ||
            SceneTreeController.IDLE_RECONNECT_IN_SECONDS);
    }
    startConnectionLostReconnectTimer() {
        this.startReconnectTimer(this.connectOptions.lostConnectionReconnectInSeconds ||
            SceneTreeController.LOST_CONNECTION_RECONNECT_IN_SECONDS);
    }
    startReconnectTimer(delayInSeconds) {
        this.clearReconnectTimer();
        this.reconnectTimer = window.setTimeout(() => {
            if (this.state.connection.type === 'connected') {
                this.connect(this.state.connection.jwtProvider);
            }
        }, delayInSeconds * 1000);
    }
    connectToViewer(viewer) {
        const connectWithViewerJwt = async () => {
            if (viewer.token != null) {
                this.log('Scene tree controller found viewer JWT. Attempting connection.');
                try {
                    await this.connect(() => viewer.token);
                }
                catch (e) {
                    this.ifErrorIsFatal(e, () => {
                        console.error('Scene tree controller errored connecting.', e);
                    });
                }
            }
            else {
                this.log('Scene tree controller was unable to find JWT.');
            }
        };
        const handleSceneReady = () => {
            this.log('Scene tree controller received scene ready');
            connectWithViewerJwt();
        };
        connectWithViewerJwt();
        viewer.addEventListener('sceneReady', handleSceneReady);
        return {
            dispose: () => {
                viewer.removeEventListener('sceneReady', handleSceneReady);
                this.disconnect();
            },
        };
    }
    disconnect(reset = false) {
        this.log(`Scene tree controller disconnecting [reset=${reset}]`);
        this.clearHandshakeTimer();
        this.clearReconnectTimer();
        this.clearLoadingTimer();
        if (reset) {
            this.pages.clear();
            this.activeRowRange = [];
        }
        const { connection } = this.state;
        if (connection.type === 'connected') {
            connection.subscription.dispose();
        }
        this.updateState({
            connection: {
                type: 'disconnected',
                jwtProvider: connection.jwtProvider,
                sceneViewId: connection.sceneViewId,
            },
            isSearching: false,
            totalRows: reset ? 0 : this.state.totalRows,
            rows: reset ? [] : this.state.rows,
            filterTerm: reset ? undefined : this.state.filterTerm,
            totalFilteredRows: reset ? undefined : this.state.totalFilteredRows,
            shouldShowEmptyResults: reset
                ? undefined
                : this.state.shouldShowEmptyResults,
            handshakeReceived: reset ? undefined : this.state.handshakeReceived,
            firstFetchComplete: reset ? undefined : this.state.firstFetchComplete,
        });
    }
    cancel() {
        this.log(`Scene tree controller cancelled`);
        this.clearHandshakeTimer();
        this.clearReconnectTimer();
        this.clearLoadingTimer();
        this.pages.clear();
        this.activeRowRange = [];
        const { connection } = this.state;
        if (connection.type === 'connected') {
            connection.subscription.dispose();
        }
        this.updateState({
            connection: {
                type: 'cancelled',
                jwtProvider: connection.jwtProvider,
                sceneViewId: connection.sceneViewId,
            },
            isSearching: false,
            totalRows: 0,
            rows: [],
        });
    }
    /**
     * Collapses a node with the given node ID.
     *
     * @param id A node ID to collapse.
     */
    async collapseNode(id) {
        return this.ifConnectionHasJwt(async (jwt) => {
            const nodeId = new Uuid();
            nodeId.setHex(id);
            const req = new CollapseNodeRequest();
            req.setNodeId(nodeId);
            await this.requestUnary(jwt, (metadata, handler) => this.client.collapseNode(req, metadata, handler));
        });
    }
    /**
     * Expands a node with the given node ID.
     *
     * @param id A node ID to expand.
     */
    async expandNode(id) {
        return this.ifConnectionHasJwt(async (jwt) => {
            const nodeId = new Uuid();
            nodeId.setHex(id);
            const req = new ExpandNodeRequest();
            req.setNodeId(nodeId);
            await this.requestUnary(jwt, (metadata, handler) => this.client.expandNode(req, metadata, handler));
        });
    }
    /**
     * Collapses all nodes in the tree.
     */
    async collapseAll() {
        return this.ifConnectionHasJwt(async (jwt) => {
            await this.requestUnary(jwt, (metadata, handler) => this.client.collapseAll(new CollapseAllRequest(), metadata, handler));
        });
    }
    /**
     * Expands all nodes in the tree.
     */
    async expandAll() {
        return this.ifConnectionHasJwt(async (jwt) => {
            await this.requestUnary(jwt, (metadata, handler) => this.client.expandAll(new ExpandAllRequest(), metadata, handler));
        });
    }
    /**
     * Invokes a network request that will expand all the parent nodes for the
     * node mapped to the given ID.
     *
     * @param id An ID of an item.
     * @returns A promise that resolves with the index of the node after
     *  expansion.
     */
    async expandParentNodes(id) {
        return this.ifConnectionHasJwt(async (jwt) => {
            const nodeId = new Uuid();
            nodeId.setHex(id);
            const req = new LocateItemRequest();
            req.setNodeId(nodeId);
            const res = await this.requestUnary(jwt, (metadata, handler) => this.client.locateItem(req, metadata, handler));
            const { requiresReload, locatedIndex } = res.toObject();
            if (requiresReload) {
                this.invalidateAfterOffset(0);
                await this.fetchUnloadedPagesInActiveRows();
            }
            if (locatedIndex == null) {
                throw new SceneTreeOperationFailedError('Cannot locate node. Location index is undefined.');
            }
            return locatedIndex.value;
        });
    }
    async fetchNodeAncestors(nodeId) {
        return this.ifConnectionHasJwt(async (jwt) => {
            const nodeUuid = new Uuid();
            nodeUuid.setHex(nodeId);
            const req = new GetNodeAncestorsRequest();
            req.setNodeId(nodeUuid);
            const res = await this.requestUnary(jwt, (metadata, handler) => this.client.getNodeAncestors(req, metadata, handler));
            return res.toObject().itemsList;
        });
    }
    /**
     * Fetches a page at the given index. Once the data has been fetched, the
     * controller will emit an `onStateChange` event that contains rows with the
     * fetched page. If a page is invalidated before the request completes, the
     * response is ignored.
     *
     * If the index is out of range, returns without fetching any data.
     *
     * @param index A 0 based index to fetch.
     */
    async fetchPage(index) {
        return this.ifConnectionHasJwt(async (jwt) => {
            if (index < 0 || index > this.maxPages - 1) {
                return;
            }
            if (!this.pages.has(index)) {
                const offset = index * this.rowLimit;
                this.log('Scene tree fetching page', index, offset);
                const res = this.fetchTree(offset, this.rowLimit, jwt);
                const id = this.nextPageId++;
                const page = { id, res, index, metadataKeys: this.metadataKeys };
                this.pages.set(index, page);
                page.done = this.handlePageResult(page);
            }
            const page = this.pages.get(index);
            const [resResult] = await Promise.allSettled([page === null || page === void 0 ? void 0 : page.res, page === null || page === void 0 ? void 0 : page.done]);
            if ((resResult === null || resResult === void 0 ? void 0 : resResult.status) === 'rejected') {
                throw resResult.reason;
            }
        });
    }
    /**
     * Fetches a page that contains the given row offset.
     *
     * @param offset The row offset of the page to fetch.
     */
    fetchPageAtOffset(offset) {
        const page = Math.floor(offset / this.rowLimit);
        return this.fetchPage(page);
    }
    /**
     * Fetches pages that contain the given row ranges.
     *
     * @param startOffset A start offset, inclusive.
     * @param endOffset The end offset, inclusive.
     */
    async fetchRange(startOffset, endOffset) {
        const startPage = Math.floor(startOffset / this.rowLimit);
        const endPage = Math.floor(endOffset / this.rowLimit);
        const [boundedStart, boundedEnd] = this.constrainPageRange(startPage, endPage);
        const pageCount = boundedEnd - boundedStart + 1;
        await Promise.all(Array.from({ length: pageCount }).map((_, page) => this.fetchPage(page)));
    }
    /**
     * Fetches the metadata keys for the current scene view.
     */
    async fetchMetadataKeys() {
        return this.ifConnectionHasJwt(async (jwt) => {
            const res = await this.requestUnary(jwt, (meta, handler) => {
                const req = new GetAvailableColumnsRequest();
                this.client.getAvailableColumns(req, meta, handler);
            });
            const indexingStatus = res.getIndexingStatus();
            this.updateState({
                ...this.state,
                isPartialKeysResponse: indexingStatus === IndexingStatus.INDEXING_STATUS_INDEXING,
            });
            return res.getKeysList().map((value) => value.getValue());
        });
    }
    /**
     * Refreshes the filter by re-filtering the current filter term.
     * @returns A promise that resolves when the filter is refreshed.
     */
    async refreshFilter() {
        var _a;
        return this.filter((_a = this.state.filterTerm) !== null && _a !== void 0 ? _a : '', this.state.filterOptions);
    }
    /**
     * Performs a network request that will filter the nodes in the tree that
     * match the given term and options.
     *
     * @param term The filter term.
     * @param options The options to apply to the filter.
     */
    async filter(term, options = {}) {
        return this.ifConnectionHasJwt(async (jwt) => {
            var _a;
            this.updateState({
                ...this.state,
                isSearching: true,
                filterTerm: term !== '' ? term : undefined,
                filterOptions: options,
            });
            // Cancel any in-flight filter requests, and wait for the request
            // to complete prior to making another filter request. This prevents
            // a race condition between requests from different keystrokes.
            (_a = this.pendingFilterGrpcRes) === null || _a === void 0 ? void 0 : _a.cancel();
            this.pendingFilterGrpcRes = undefined;
            if (this.state.filterTerm === term || term === '') {
                try {
                    const res = await this.requestUnary(jwt, (metadata, handler) => {
                        var _a;
                        const req = new FilterRequest();
                        req.setFilter(term);
                        req.setFullTree((_a = options.includeCollapsed) !== null && _a !== void 0 ? _a : true);
                        req.setExactMatch(!!options.exactMatch);
                        req.setRemoveHiddenItems(!!options.removeHiddenItems);
                        if (options.columns)
                            req.setColumnsKeysList(options.columns);
                        this.pendingFilterGrpcRes = this.client.filter(req, metadata, handler);
                    });
                    const { numberOfResults, indexingStatus } = res.toObject();
                    this.updateState({
                        ...this.state,
                        totalFilteredRows: numberOfResults,
                        isSearching: false,
                        isPartialFilterResponse: indexingStatus === IndexingStatus.INDEXING_STATUS_INDEXING,
                    });
                }
                catch (e) {
                    console.error('Failed to filter search ', e);
                    this.updateState({
                        ...this.state,
                        isSearching: false,
                    });
                }
            }
        });
    }
    /**
     * Checks if the page at the given index is loaded.
     *
     * @param index A page index.
     * @returns `true` if the page is loaded. `false` otherwise.
     */
    isPageLoaded(index) {
        return this.pages.has(index);
    }
    /**
     * Returns a list of page indices within a range that have not been loaded.
     *
     * @param start The start page index, inclusive.
     * @param end The end page index, inclusive.
     * @returns A list of pages indices that have not been loaded.
     */
    getNonLoadedPageIndexes(start, end) {
        const [boundedStart, boundedEnd] = this.constrainPageRange(start, end);
        const pageCount = boundedEnd - boundedStart + 1;
        return Array.from({ length: pageCount })
            .map((_, i) => boundedStart + i)
            .filter((page) => !this.isPageLoaded(page));
    }
    /**
     * @internal
     * @ignore
     *
     * Visible for testing.
     */
    getActiveRowRange() {
        return this.activeRowRange;
    }
    /**
     * Returns the page at the given index, or `undefined` if one doesn't exist.
     *
     * @param index The index to return.
     * @returns A `Page` if found, otherwise `undefined`.
     */
    getPage(index) {
        return this.pages.get(index);
    }
    /**
     * Clears page data that is outside a given range. Uses a distance algorithm
     * to removes pages that are furthest from either end of the range. This
     * method is useful for clearing out data that is not needed anymore to keep
     * memory pressure in the client low.
     *
     * @param startPage The index of the starting page in the range.
     * @param endPage The index of the ending page in the range.
     * @param threshold A minimum number of pages to keep. No cleanup is performed
     *  if the number of fetched pages doesn't meet this threshold.
     */
    invalidatePagesOutsideRange(startPage, endPage, threshold = 0) {
        const [boundedStart, boundedEnd] = this.constrainPageRange(startPage, endPage);
        const boundedThreshold = Math.max(boundedEnd - boundedStart, threshold);
        if (this.fetchedPageCount > boundedThreshold) {
            const pages = Array.from(this.pages.keys()).map((index) => {
                const distance = index < boundedStart ? boundedStart - index : index - boundedEnd;
                return { index, distance };
            });
            const sortedDesc = pages.sort((a, b) => b.distance - a.distance);
            sortedDesc
                .slice(0, pages.length - threshold)
                .forEach(({ index }) => this.invalidatePage(index));
            this.log(`Scene tree dropped ${pages.length - this.fetchedPageCount} pages`, this.pages);
        }
    }
    setMetadataKeys(keys) {
        this.metadataKeys = keys;
        if (this.state.connection.type === 'connected') {
            const [start, end] = this.activeRowRange;
            this.invalidateAfterOffset(0);
            return this.updateActiveRowRange(start, end);
        }
        else {
            return Promise.resolve();
        }
    }
    /**
     * Sets the active rows. Active rows dictate which pages will be refetched
     * when the tree changes.
     *
     * @param start The starting row index.
     * @param end The ending row index.
     * @returns A promise that resolves when the first page of data has been
     *  loaded.
     */
    async updateActiveRowRange(start, end) {
        this.activeRowRange = this.constrainRowOffsets(start, end);
        this.tryClearLoadingState();
        await this.fetchUnloadedPagesInActiveRows();
    }
    /**
     * Performs a network request that will listen to server-side changes of the
     * scene tree's data.
     */
    subscribe() {
        return this.ifConnectionHasJwt((jwt) => {
            const stream = this.requestServerStream(jwt, (metadata) => {
                const sub = this.client.subscribe(new SubscribeRequest(), metadata);
                return sub;
            });
            stream.on('data', (msg) => {
                if (msg.hasHandshake()) {
                    this.clearHandshakeTimer();
                    this.updateState({ ...this.state, handshakeReceived: true });
                    // Verify that we've loaded the first page of the tree when we receive
                    // a handshake in the case that the listener was not fully registered
                    // when a `ListChange` was sent.
                    if (this.state.firstFetchComplete) {
                        this.log('Scene tree first fetch completed before handshake received, invalidating current pages');
                        this.invalidateAfterOffset(0);
                        this.fetchUnloadedPagesInActiveRows();
                    }
                }
                this.startIdleReconnectTimer();
                const { change } = msg.toObject();
                // Update the number of filtered results if changed
                if ((change === null || change === void 0 ? void 0 : change.filterChange) != null) {
                    this.updateState({
                        ...this.state,
                        totalFilteredRows: change.filterChange.numberOfResults,
                    });
                }
                if ((change === null || change === void 0 ? void 0 : change.listChange) != null) {
                    this.log('Received list change', change.listChange.start);
                    this.invalidateAfterOffset(change.listChange.start);
                    this.fetchUnloadedPagesInActiveRows();
                }
                const { hiddenList = [], shownList = [], partiallyVisibleList = [], deselectedList = [], selectedList = [], } = (change === null || change === void 0 ? void 0 : change.ranges) || {};
                if (partiallyVisibleList.length > 0) {
                    this.log('Received partial visibility list change', partiallyVisibleList);
                    partiallyVisibleList.forEach(({ start, end }) => this.patchNodesInRange(start, end, () => ({
                        partiallyVisible: true,
                    })));
                }
                if (hiddenList.length > 0) {
                    this.log('Received hidden list change', hiddenList);
                    hiddenList.forEach(({ start, end }) => this.patchNodesInRange(start, end, () => ({
                        visible: false,
                        partiallyVisible: false,
                    })));
                }
                if (shownList.length > 0) {
                    this.log('Received shown list change', shownList);
                    shownList.forEach(({ start, end }) => this.patchNodesInRange(start, end, () => ({
                        visible: true,
                        partiallyVisible: false,
                    })));
                }
                if (deselectedList.length > 0) {
                    this.log('Received deselected list change', deselectedList);
                    deselectedList.forEach(({ start, end }) => this.patchNodesInRange(start, end, () => ({ selected: false })));
                }
                if (selectedList.length > 0) {
                    this.log('Received selected list change', selectedList);
                    selectedList.forEach(({ start, end }) => this.patchNodesInRange(start, end, () => ({ selected: true })));
                }
            });
            stream.on('status', (s) => {
                if (s.code !== 0) {
                    console.error(`Failed to subscribe to scene tree with code=${s.code}, details=${s.details}`);
                    this.invalidateAfterOffset(0);
                }
            });
            stream.on('end', () => {
                this.invalidateAfterOffset(0);
                if (this.state.connection.type === 'connected') {
                    this.startConnectionLostReconnectTimer();
                }
            });
            return stream;
        });
    }
    async fetchUnloadedPagesInActiveRows() {
        var _a, _b;
        const [startPage, endPage] = this.getPageIndexesForRange(
        // Verify the first page is loaded properly even if the `activeRowRange`
        // has not been specified by the underlying table layout after a reconnect.
        (_a = this.activeRowRange[0]) !== null && _a !== void 0 ? _a : 0, (_b = this.activeRowRange[1]) !== null && _b !== void 0 ? _b : 0);
        const pages = this.getNonLoadedPageIndexes(startPage - 1, endPage + 1);
        await Promise.all(pages.map((page) => this.fetchPage(page)));
    }
    patchNodesInRange(start, end, transform) {
        const updatedRows = this.state.rows
            .slice(start, end + 1)
            .map((row) => isLoadedRow(row)
            ? { ...row, node: { ...row.node, ...transform(row.node) } }
            : row);
        const startRows = this.state.rows.slice(0, start);
        const endRows = this.state.rows.slice(end + 1);
        const rows = [...startRows, ...updatedRows, ...endRows];
        this.updateState({ ...this.state, rows });
    }
    async handlePageResult(page) {
        var _a;
        try {
            const res = await page.res;
            const currentPage = this.getPage(page.index);
            // Only handle the result if the page has not been invalidated.
            if ((currentPage === null || currentPage === void 0 ? void 0 : currentPage.id) === page.id) {
                const cursor = res.getCursor();
                const itemsList = res.getItemsList();
                const totalRows = (_a = cursor === null || cursor === void 0 ? void 0 : cursor.getTotal()) !== null && _a !== void 0 ? _a : 0;
                const offset = page.index * this.rowLimit;
                const fetchedRows = fromNodeProto(offset, itemsList, currentPage.metadataKeys);
                const start = this.state.rows.slice(0, offset);
                const end = this.state.rows.slice(start.length + fetchedRows.length, totalRows);
                const fill = new Array(Math.max(0, totalRows - start.length - fetchedRows.length - end.length));
                const rows = [...start, ...fetchedRows, ...end, ...fill];
                if (this.isViewLoading(rows) && this.loadingTimer == null) {
                    this.restartLoadingTimer();
                    this.updateState({
                        ...this.state,
                        totalRows: totalRows,
                        rows: rows,
                        shouldShowEmptyResults: this.state.filterTerm != null &&
                            (rows.length === 0 || this.state.totalFilteredRows === 0),
                    });
                }
                else {
                    this.updateState({
                        ...this.state,
                        totalRows: totalRows,
                        rows: rows,
                        shouldShowLoading: rows.length === 0 && this.state.shouldShowLoading,
                        shouldShowEmptyResults: this.state.filterTerm != null &&
                            (rows.length === 0 || this.state.totalFilteredRows === 0),
                        firstFetchComplete: true,
                    });
                }
            }
        }
        catch (e) {
            const errorMessage = e instanceof Error ? e.toString() : 'Unknown';
            console.error(`Request error fetching page at index ${page.index} (${errorMessage})`);
            const currentPage = this.getPage(page.index);
            if ((currentPage === null || currentPage === void 0 ? void 0 : currentPage.id) === page.id) {
                this.invalidatePage(page.index);
            }
        }
    }
    invalidatePage(index) {
        if (this.isPageLoaded(index)) {
            this.pages.delete(index);
        }
    }
    invalidateAfterOffset(offset) {
        const pageIndex = Math.floor(offset / this.rowLimit);
        for (const index of this.pages.keys()) {
            if (index >= pageIndex) {
                this.invalidatePage(index);
            }
        }
    }
    getState() {
        return this.state;
    }
    isViewLoading(rows) {
        return (this.state.filterTerm == null &&
            rows[this.activeRowRange[0]] == null &&
            rows[this.activeRowRange[1]] == null);
    }
    restartLoadingTimer() {
        if (this.loadingTimer != null) {
            this.clearLoadingTimer();
        }
        this.loadingTimer = window.setTimeout(() => {
            this.loadingTimer = undefined;
            this.updateState({
                ...this.getState(),
                shouldShowLoading: true,
            });
        }, this.connectOptions.spinnerDelay);
    }
    clearLoadingTimer() {
        if (this.loadingTimer != null) {
            clearTimeout(this.loadingTimer);
            this.loadingTimer = undefined;
        }
    }
    tryClearLoadingState() {
        const didClearLoadingTimer = this.tryClearLoadingTimer(this.state);
        if (didClearLoadingTimer) {
            this.updateState({ ...this.state, shouldShowLoading: false });
        }
    }
    tryClearLoadingTimer(state) {
        const loadingShowingOrTimerStarted = this.state.shouldShowLoading || this.loadingTimer != null;
        const updateLoadingTimer = loadingShowingOrTimerStarted && !this.isViewLoading(state.rows);
        if (updateLoadingTimer) {
            this.clearLoadingTimer();
            return true;
        }
        return false;
    }
    /**
     * @internal
     * @ignore
     *
     * Visible for testing.
     */
    updateState(newState) {
        const didClearLoadingTimer = this.tryClearLoadingTimer(newState);
        this.state = {
            ...newState,
            shouldShowLoading: didClearLoadingTimer
                ? false
                : newState.shouldShowLoading,
        };
        this.onStateChange.emit(this.state);
    }
    getPageForOffset(offset) {
        const index = Math.floor(offset / this.rowLimit);
        return this.constrainPageIndex(index);
    }
    getPageIndexesForRange(start, end) {
        const pageStart = this.getPageForOffset(start);
        const pageEnd = this.getPageForOffset(end);
        return [pageStart, pageEnd];
    }
    async fetchTree(offset, limit, jwt) {
        return this.requestUnary(jwt, (metadata, handler) => {
            const pager = new OffsetPager();
            pager.setOffset(offset);
            pager.setLimit(limit);
            const req = new GetTreeRequest();
            req.setPager(pager);
            req.setAdditionalColumnKeysList(this.metadataKeys);
            this.client.getTree(req, metadata, handler);
        });
    }
    // TODO(dan): used shared grpc lib
    requestUnary(jwt, req) {
        return new Promise((resolve, reject) => {
            const metadata = this.createJwtMetadata(jwt);
            req(metadata, (err, res) => {
                if (err != null) {
                    reject(err);
                }
                else if (res != null) {
                    resolve(res);
                }
                else {
                    reject(new SceneTreeError('Invalid response. Both error and result are null'));
                }
            });
        });
    }
    // TODO(dan): move to shared grpc lib
    requestServerStream(jwt, req) {
        const metadata = this.createJwtMetadata(jwt);
        return req(metadata);
    }
    createJwtMetadata(jwt) {
        return new grpc.Metadata({
            'jwt-context': JSON.stringify({ jwt }),
        });
    }
    async ifConnectionHasJwt(then) {
        const { connection } = this.state;
        if (connection.type === 'connecting' || connection.type === 'connected') {
            const jwt = await connection.jwtProvider();
            if (jwt != null) {
                return then(jwt);
            }
            else {
                throw new SceneTreeUnauthorizedError('SceneTreeController cannot perform request. Viewer JWT is undefined.');
            }
        }
        else if (connection.type === 'cancelled') {
            throw new SceneTreeConnectionCancelledError(`Request attempted, but controller was cancelled`);
        }
        else {
            throw new SceneTreeError('SceneTreeController is not in connected state');
        }
    }
    ifErrorIsFatal(error, then) {
        if (!(error instanceof SceneTreeConnectionCancelledError)) {
            then();
        }
    }
    getConnectionError(e) {
        if (isGrpcServiceError(e)) {
            if (e.code === grpc.Code.FailedPrecondition) {
                return new SceneTreeErrorDetails('SCENE_TREE_DISABLED', SceneTreeErrorCode.SCENE_TREE_DISABLED);
            }
            else if (e.code === grpc.Code.Unauthenticated) {
                return new SceneTreeErrorDetails('UNAUTHORIZED', SceneTreeErrorCode.UNAUTHORIZED);
            }
            else if (e.code === grpc.Code.Aborted) {
                return new SceneTreeErrorDetails('ABORTED', SceneTreeErrorCode.ABORTED);
            }
            else {
                return new SceneTreeErrorDetails('UNKNOWN', SceneTreeErrorCode.UNKNOWN);
            }
        }
        else if (e instanceof SceneTreeUnauthorizedError) {
            return new SceneTreeErrorDetails('UNAUTHORIZED', SceneTreeErrorCode.UNAUTHORIZED);
        }
        else {
            return new SceneTreeErrorDetails('UNKNOWN', SceneTreeErrorCode.UNKNOWN);
        }
    }
    constrainPageRange(start, end) {
        return [Math.max(0, start), Math.min(this.maxPages - 1, end)];
    }
    constrainPageIndex(index) {
        return Math.max(0, Math.min(index, this.maxPages - 1));
    }
    constrainRowOffsets(start, end) {
        const constrainedStart = !Number.isNaN(start) ? Math.max(0, start) : 0;
        const constrainedEnd = !Number.isNaN(end)
            ? Math.min(this.state.totalRows - 1, end)
            : this.state.totalRows - 1;
        return [constrainedStart, constrainedEnd];
    }
    get maxPages() {
        return Math.max(1, Math.ceil(this.state.totalRows / this.rowLimit));
    }
    log(message, ...optionalParams) {
        if (this.debugLogs) {
            console.debug(message, optionalParams);
        }
    }
    isConnectedState(connection) {
        return connection.type === 'connected';
    }
}
SceneTreeController.IDLE_RECONNECT_IN_SECONDS = 4 * 60;
SceneTreeController.LOST_CONNECTION_RECONNECT_IN_SECONDS = 2;
SceneTreeController.MAX_SUBSCRIPTION_RETRY_COUNT = 2;
//# sourceMappingURL=controller.js.map
