"use strict";
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
 * @module iModels
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.CheckpointManager = exports.V2CheckpointManager = exports.Downloads = exports.ProgressStatus = void 0;
// cspell:ignore BLOCKCACHE
const path = require("path");
const imodeljs_native_1 = require("@bentley/imodeljs-native");
const core_bentley_1 = require("@itwin/core-bentley");
const core_common_1 = require("@itwin/core-common");
const BackendLoggerCategory_1 = require("./BackendLoggerCategory");
const BriefcaseManager_1 = require("./BriefcaseManager");
const CloudSqlite_1 = require("./CloudSqlite");
const IModelHost_1 = require("./IModelHost");
const IModelJsFs_1 = require("./IModelJsFs");
const IModelDb_1 = require("./IModelDb");
const NativePlatform_1 = require("./internal/NativePlatform");
const Symbols_1 = require("./internal/Symbols");
const loggerCategory = BackendLoggerCategory_1.BackendLoggerCategory.IModelDb;
/** Return value from [[ProgressFunction]].
 *  @public
 */
var ProgressStatus;
(function (ProgressStatus) {
    /** Continue download. */
    ProgressStatus[ProgressStatus["Continue"] = 0] = "Continue";
    /** Abort download. */
    ProgressStatus[ProgressStatus["Abort"] = 1] = "Abort";
})(ProgressStatus || (exports.ProgressStatus = ProgressStatus = {}));
/** @internal */
class Downloads {
    static _active = new Map();
    static async process(job, fn) {
        const jobName = job.request.localFile; // save this, it can change inside call to `fn`!
        this._active.set(jobName, job);
        try {
            return await fn(job);
        }
        finally {
            this._active.delete(jobName);
        }
    }
    static isInProgress(pathName) {
        return this._active.get(pathName);
    }
    static async download(request, downloadFn) {
        const pathName = request.localFile;
        let job = this.isInProgress(pathName);
        if (undefined !== job)
            return job.promise;
        IModelJsFs_1.IModelJsFs.recursiveMkDirSync(path.dirname(pathName));
        job = { request };
        return job.promise = this.process(job, downloadFn);
    }
}
exports.Downloads = Downloads;
/**
 * Utility class for opening V2 checkpoints from cloud containers, and also for downloading them.
 * @internal
*/
class V2CheckpointManager {
    static cloudCacheName = "Checkpoints";
    static _cloudCache;
    static containers = new Map();
    /** used by HubMock
     * @internal
     */
    static [Symbols_1._mockCheckpoint];
    static getFolder() {
        const cloudCachePath = path.join(BriefcaseManager_1.BriefcaseManager.cacheDir, V2CheckpointManager.cloudCacheName);
        if (!(IModelJsFs_1.IModelJsFs.existsSync(cloudCachePath))) {
            IModelJsFs_1.IModelJsFs.recursiveMkDirSync(cloudCachePath);
        }
        return cloudCachePath;
    }
    /* only used by tests that reset the state of the v2CheckpointManager. all dbs should be closed before calling this function. */
    static cleanup() {
        for (const [_, value] of this.containers.entries()) {
            if (value.isConnected)
                value.disconnect({ detach: true });
        }
        CloudSqlite_1.CloudSqlite.CloudCaches.dropCache(this.cloudCacheName)?.destroy();
        this._cloudCache = undefined;
        this.containers.clear();
    }
    static get cloudCache() {
        if (!this._cloudCache) {
            let cacheDir = process.env.CHECKPOINT_CACHE_DIR ?? this.getFolder();
            // See if there is a daemon running, otherwise use profile directory for cloudCache
            if (!(IModelJsFs_1.IModelJsFs.existsSync(path.join(cacheDir, "portnumber.bcv"))))
                cacheDir = undefined; // no daemon running, use profile directory
            this._cloudCache = CloudSqlite_1.CloudSqlite.CloudCaches.getCache({ cacheName: this.cloudCacheName, cacheDir, cacheSize: "50G" });
        }
        return this._cloudCache;
    }
    /** Member names differ slightly between the V2Checkpoint api and the CloudSqlite api. Add aliases `accessName` for `accountName` and `accessToken` for `sasToken` */
    static toCloudContainerProps(from) {
        return { ...from, baseUri: `https://${from.accountName}.blob.core.windows.net`, accessToken: from.sasToken, storageType: "azure" };
    }
    static getContainer(v2Props, checkpoint) {
        let container = this.containers.get(v2Props.containerId);
        if (undefined === container) {
            let tokenFn;
            let tokenRefreshSeconds = -1;
            // from Rpc, the accessToken in the checkpoint request is from the current user. It is used to request the sasToken for the container and
            // the sasToken is checked for refresh (before it expires) on every Rpc request using that user's accessToken. For Ipc, the
            // accessToken in the checkpoint request is undefined, and the sasToken is requested by IModelHost.getAccessToken(). It is refreshed on a timer.
            if (undefined === checkpoint.accessToken) {
                tokenFn = async () => (await IModelHost_1.IModelHost[Symbols_1._hubAccess].queryV2Checkpoint(checkpoint))?.sasToken ?? "";
                tokenRefreshSeconds = undefined;
            }
            container = CloudSqlite_1.CloudSqlite.createCloudContainer({ ...this.toCloudContainerProps(v2Props), tokenRefreshSeconds, logId: process.env.POD_NAME, tokenFn });
            this.containers.set(v2Props.containerId, container);
        }
        return container;
    }
    static async attach(checkpoint) {
        if (this[Symbols_1._mockCheckpoint]) // used by HubMock
            return { dbName: this[Symbols_1._mockCheckpoint].mockAttach(checkpoint), container: undefined };
        let v2props;
        try {
            v2props = await IModelHost_1.IModelHost[Symbols_1._hubAccess].queryV2Checkpoint(checkpoint);
            if (!v2props)
                throw new Error("no checkpoint");
        }
        catch (err) {
            throw new core_common_1.IModelError(core_bentley_1.IModelStatus.NotFound, `V2 checkpoint not found: err: ${err.message}`);
        }
        try {
            const container = this.getContainer(v2props, checkpoint);
            const dbName = v2props.dbName;
            // Use the new token from the recently queried v2 checkpoint just incase the one we currently have is expired.
            container.accessToken = v2props.sasToken;
            if (!container.isConnected)
                container.connect(this.cloudCache);
            container.checkForChanges();
            const dbStats = container.queryDatabase(dbName);
            if (IModelHost_1.IModelHost.appWorkspace.settings.getBoolean("Checkpoints/prefetch", false)) {
                const getPrefetchConfig = (name, defaultVal) => IModelHost_1.IModelHost.appWorkspace.settings.getNumber(`Checkpoints/prefetch/${name}`, defaultVal);
                const minRequests = getPrefetchConfig("minRequests", 3);
                const maxRequests = getPrefetchConfig("maxRequests", 6);
                const timeout = getPrefetchConfig("timeout", 100);
                const maxBlocks = getPrefetchConfig("maxBlocks", 500); // default size of 2GB. Assumes a checkpoint block size of 4MB.
                if (dbStats?.totalBlocks !== undefined && dbStats.totalBlocks <= maxBlocks && dbStats.nPrefetch === 0) {
                    const logPrefetch = async (prefetch) => {
                        const stopwatch = new core_bentley_1.StopWatch(`[${container.containerId}/${dbName}]`, true);
                        core_bentley_1.Logger.logInfo(loggerCategory, `Starting prefetch of ${stopwatch.description}`, { minRequests, maxRequests, timeout });
                        const done = await prefetch.promise;
                        core_bentley_1.Logger.logInfo(loggerCategory, `Prefetch of ${stopwatch.description} complete=${done} (${stopwatch.elapsedSeconds} seconds)`, { minRequests, maxRequests, timeout });
                    };
                    // eslint-disable-next-line @typescript-eslint/no-floating-promises
                    logPrefetch(CloudSqlite_1.CloudSqlite.startCloudPrefetch(container, dbName, { minRequests, nRequests: maxRequests, timeout }));
                }
                else {
                    core_bentley_1.Logger.logInfo(loggerCategory, `Skipping prefetch due to size limits or ongoing prefetch.`, { maxBlocks, numPrefetches: dbStats?.nPrefetch, totalBlocksInDb: dbStats?.totalBlocks, v2props });
                }
            }
            return { dbName, container };
        }
        catch (e) {
            const error = `Cloud cache connect failed: ${e.message}`;
            if (checkpoint.expectV2)
                core_bentley_1.Logger.logError(loggerCategory, error);
            throw new core_common_1.IModelError(e.errorNumber, error);
        }
    }
    /** @internal */
    static async performDownload(job) {
        const request = job.request;
        if (this[Symbols_1._mockCheckpoint])
            this[Symbols_1._mockCheckpoint].mockDownload(request);
        else {
            const v2props = await IModelHost_1.IModelHost[Symbols_1._hubAccess].queryV2Checkpoint({ ...request.checkpoint, allowPreceding: true });
            if (!v2props)
                throw new core_common_1.IModelError(core_bentley_1.IModelStatus.NotFound, "V2 checkpoint not found");
            CheckpointManager.onDownloadV2.raiseEvent(job);
            const container = CloudSqlite_1.CloudSqlite.createCloudContainer(this.toCloudContainerProps(v2props));
            await CloudSqlite_1.CloudSqlite.transferDb("download", container, { dbName: v2props.dbName, localFileName: request.localFile, onProgress: request.onProgress });
        }
        return request.checkpoint.changeset.id;
    }
    /** Fully download a V2 checkpoint to a local file that can be used to create a briefcase or to work offline.
     * @returns a Promise that is resolved when the download completes with the changesetId of the downloaded checkpoint (which will
     * be the same as the requested changesetId or the most recent checkpoint before it.)
     */
    static async downloadCheckpoint(request) {
        return Downloads.download(request, async (job) => this.performDownload(job));
    }
}
exports.V2CheckpointManager = V2CheckpointManager;
/** @internal  */
class CheckpointManager {
    static onDownloadV2 = new core_bentley_1.BeEvent();
    static getKey(checkpoint) { return `${checkpoint.iModelId}:${checkpoint.changeset.id}`; }
    static async doDownload(request) {
        // first see if there's a V2 checkpoint available.
        const stopwatch = new core_bentley_1.StopWatch(`[${request.checkpoint.changeset.id}]`, true);
        core_bentley_1.Logger.logInfo(loggerCategory, `Starting download of V2 checkpoint with id ${stopwatch.description}`);
        const changesetId = await V2CheckpointManager.downloadCheckpoint(request);
        core_bentley_1.Logger.logInfo(loggerCategory, `Downloaded V2 checkpoint with id ${stopwatch.description} (${stopwatch.elapsedSeconds} seconds)`);
        if (changesetId !== request.checkpoint.changeset.id)
            core_bentley_1.Logger.logInfo(loggerCategory, `Downloaded previous v2 checkpoint because requested checkpoint not found.`, { requestedChangesetId: request.checkpoint.changeset.id, iModelId: request.checkpoint.iModelId, changesetId, iTwinId: request.checkpoint.iTwinId });
        else
            core_bentley_1.Logger.logInfo(loggerCategory, `Downloaded v2 checkpoint.`, { iModelId: request.checkpoint.iModelId, changesetId: request.checkpoint.changeset.id, iTwinId: request.checkpoint.iTwinId });
        return changesetId;
    }
    static async updateToRequestedVersion(request) {
        const checkpoint = request.checkpoint;
        const targetFile = request.localFile;
        const traceInfo = { iTwinId: checkpoint.iTwinId, iModelId: checkpoint.iModelId, changeset: checkpoint.changeset };
        try {
            // Open checkpoint for write
            const prevLogLevel = core_bentley_1.Logger.getLevel(imodeljs_native_1.NativeLoggerCategory.SQLite) ?? core_bentley_1.LogLevel.Error; // Get log level before we set it to None.
            core_bentley_1.Logger.setLevel(imodeljs_native_1.NativeLoggerCategory.SQLite, core_bentley_1.LogLevel.None); // Ignores noisy error messages when applying changesets.
            const db = IModelDb_1.SnapshotDb.openForApplyChangesets(targetFile);
            const nativeDb = db[Symbols_1._nativeDb];
            try {
                if (nativeDb.hasPendingTxns()) {
                    core_bentley_1.Logger.logWarning(loggerCategory, "Checkpoint with Txns found - deleting them", () => traceInfo);
                    nativeDb.deleteAllTxns();
                }
                if (nativeDb.getBriefcaseId() !== core_common_1.BriefcaseIdValue.Unassigned)
                    nativeDb.resetBriefcaseId(core_common_1.BriefcaseIdValue.Unassigned);
                CheckpointManager.validateCheckpointGuids(checkpoint, db);
                // Apply change sets if necessary
                const currentChangeset = nativeDb.getCurrentChangeset();
                if (currentChangeset.id !== checkpoint.changeset.id) {
                    const accessToken = checkpoint.accessToken;
                    const toIndex = checkpoint.changeset.index ??
                        (await IModelHost_1.IModelHost[Symbols_1._hubAccess].getChangesetFromVersion({ accessToken, iModelId: checkpoint.iModelId, version: core_common_1.IModelVersion.asOfChangeSet(checkpoint.changeset.id) })).index;
                    await BriefcaseManager_1.BriefcaseManager.pullAndApplyChangesets(db, { accessToken, toIndex });
                }
                else {
                    // make sure the parent changeset index is saved in the file - old versions didn't have it.
                    currentChangeset.index = checkpoint.changeset.index; // eslint-disable-line @typescript-eslint/no-non-null-assertion
                    nativeDb.saveLocalValue("parentChangeSet", JSON.stringify(currentChangeset));
                }
            }
            finally {
                core_bentley_1.Logger.setLevel(imodeljs_native_1.NativeLoggerCategory.SQLite, prevLogLevel); // Set logging to what it was before we started applying changesets.
                db.saveChanges();
                db.close();
            }
        }
        catch (error) {
            core_bentley_1.Logger.logError(loggerCategory, "Error downloading checkpoint - deleting it", () => traceInfo);
            IModelJsFs_1.IModelJsFs.removeSync(targetFile);
            if (error.errorNumber === core_bentley_1.ChangeSetStatus.CorruptedChangeStream || error.errorNumber === core_bentley_1.ChangeSetStatus.InvalidId || error.errorNumber === core_bentley_1.ChangeSetStatus.InvalidVersion) {
                core_bentley_1.Logger.logError(loggerCategory, "Detected potential corruption of change sets. Deleting them to enable retries", () => traceInfo);
                BriefcaseManager_1.BriefcaseManager.deleteChangeSetsFromLocalDisk(checkpoint.iModelId);
            }
            throw error;
        }
    }
    /** Download a checkpoint file from iModelHub into a local file specified in the request parameters. */
    static async downloadCheckpoint(request) {
        if (this.verifyCheckpoint(request.checkpoint, request.localFile))
            return;
        if (request.aliasFiles) {
            for (const alias of request.aliasFiles) {
                if (this.verifyCheckpoint(request.checkpoint, alias)) {
                    request.localFile = alias;
                    return;
                }
            }
        }
        await this.doDownload(request);
        return this.updateToRequestedVersion(request);
    }
    /** checks a file's dbGuid & iTwinId for consistency, and updates the dbGuid when possible */
    static validateCheckpointGuids(checkpoint, snapshotDb) {
        const traceInfo = { iTwinId: checkpoint.iTwinId, iModelId: checkpoint.iModelId };
        const nativeDb = snapshotDb[Symbols_1._nativeDb];
        const dbChangeset = nativeDb.getCurrentChangeset();
        const iModelId = core_bentley_1.Guid.normalize(nativeDb.getIModelId());
        if (iModelId !== core_bentley_1.Guid.normalize(checkpoint.iModelId)) {
            if (nativeDb.isReadonly())
                throw new core_common_1.IModelError(core_bentley_1.IModelStatus.ValidationFailed, "iModelId is not properly set up in the checkpoint");
            core_bentley_1.Logger.logWarning(loggerCategory, "iModelId is not properly set up in the checkpoint. Updated checkpoint to the correct iModelId.", () => ({ ...traceInfo, dbGuid: iModelId }));
            const iModelIdNormalized = core_bentley_1.Guid.normalize(checkpoint.iModelId);
            nativeDb.setIModelId(iModelIdNormalized);
            snapshotDb._iModelId = iModelIdNormalized;
            // Required to reset the ChangeSetId because setDbGuid clears the value.
            nativeDb.saveLocalValue("ParentChangeSetId", dbChangeset.id);
            if (undefined !== dbChangeset.index)
                nativeDb.saveLocalValue("parentChangeSet", JSON.stringify(dbChangeset));
        }
        const iTwinId = core_bentley_1.Guid.normalize(nativeDb.getITwinId());
        if (iTwinId !== core_bentley_1.Guid.normalize(checkpoint.iTwinId))
            throw new core_common_1.IModelError(core_bentley_1.IModelStatus.ValidationFailed, "iTwinId was not properly set up in the checkpoint");
    }
    /** @returns true if the file is the checkpoint requested */
    static verifyCheckpoint(checkpoint, fileName) {
        if (!IModelJsFs_1.IModelJsFs.existsSync(fileName))
            return false;
        const nativeDb = new NativePlatform_1.IModelNative.platform.DgnDb();
        try {
            nativeDb.openIModel(fileName, core_bentley_1.OpenMode.Readonly);
        }
        catch {
            return false;
        }
        const isValid = checkpoint.iModelId === nativeDb.getIModelId() && checkpoint.changeset.id === nativeDb.getCurrentChangeset().id;
        nativeDb.closeFile();
        if (!isValid)
            IModelJsFs_1.IModelJsFs.removeSync(fileName);
        return isValid;
    }
    static async toCheckpointProps(args) {
        const changeset = args.changeset ?? await IModelHost_1.IModelHost[Symbols_1._hubAccess].getLatestChangeset({ ...args, accessToken: await IModelHost_1.IModelHost.getAccessToken() });
        return {
            iModelId: args.iModelId,
            iTwinId: args.iTwinId,
            changeset: {
                index: changeset.index,
                id: changeset.id ?? (await IModelHost_1.IModelHost[Symbols_1._hubAccess].queryChangeset({ ...args, changeset, accessToken: await IModelHost_1.IModelHost.getAccessToken() })).id,
            },
        };
    }
}
exports.CheckpointManager = CheckpointManager;
//# sourceMappingURL=CheckpointManager.js.map