import { SyncronizationStatus } from "@superblocksteam/library-shared/types";
import { addTracingToMethods, TracedEventEmitter, } from "@superblocksteam/shared";
import { BadRequestError } from "@superblocksteam/shared";
import { unwrapResponseDto } from "@superblocksteam/shared";
import { normalizePath } from "vite";
import { applyErrorHandling, } from "../errors/error-handler.js";
import { makeServerError } from "../errors/make-server-error.js";
import { getErrorMeta } from "../util/logger.js";
import { OperationQueue } from "../util/operation-queue.js";
import { withResolvers } from "../util/with-resolvers.js";
import { downloadApplicationDirectory } from "./download.js";
import { hasSwapFiles, discardAllDrafts, commitAllDrafts, getDraftInfo, createDraft, generateDraftDiff, createDraftMarkerOnly, createDraftDelete, cleanUpDraftsDirectory, } from "./draft-helpers.js";
import { HashCache } from "./hash-cache.js";
import { snapshotLocalDirectoryWithDrafts } from "./snapshot/take-snapshot.js";
import { uploadDirectorySnapshot } from "./snapshot/upload-snapshot.js";
const SYNC_INTERVAL_MS = 10_000;
export var SyncServiceStatus;
(function (SyncServiceStatus) {
    /** The sync service is not currently syncing or scheduled to sync */
    SyncServiceStatus["IDLE"] = "IDLE";
    /** The sync service is scheduled to sync */
    SyncServiceStatus["SCHEDULED"] = "SCHEDULED";
    /** The sync service is currently syncing server -> local */
    SyncServiceStatus["DOWNLOADING"] = "DOWNLOADING";
    /** The sync service is currently syncing local -> server */
    SyncServiceStatus["UPLOADING"] = "UPLOADING";
    /** The sync service is currently paused */
    SyncServiceStatus["PAUSED"] = "PAUSED";
})(SyncServiceStatus || (SyncServiceStatus = {}));
function isDevOrLocal() {
    const env = process.env.SUPERBLOCKS_SERVER_ENV ?? process.env.SUPERBLOCKS_ENVIRONMENT;
    return env === "dev" || env === "local";
}
/**
 * Returns log level for sync errors.
 * Shutdown/connection closed = warn (expected during teardown or transient).
 * Page not found = warn in dev/local (expected in ephemeral), error elsewhere.
 */
function getSyncErrorLogLevel(error) {
    const msg = error?.message ?? "";
    if (msg.includes("RPC client is shutting down") ||
        msg.includes("Connection closed")) {
        return "warn";
    }
    if (msg.includes("Page not found")) {
        return isDevOrLocal() ? "warn" : "error";
    }
    return "error";
}
function logSyncError(logger, error, message) {
    const level = getSyncErrorLogLevel(error);
    if (level === "warn") {
        // Logger.warn() has a variadic (...messages) signature and does not
        // forward the second argument as structured ErrorMeta to log listeners.
        // Use logger.error() so that meta is preserved, but include
        // "[expected]" so it's clear this is a downgraded/expected error.
        logger.error(`${message} [expected]`, getErrorMeta(error));
    }
    else {
        logger.error(message, getErrorMeta(error));
    }
}
export class SyncService extends TracedEventEmitter {
    appRootDirPath;
    applicationId;
    branchName;
    generationNumberSequence;
    lastSyncedGenerationNumber;
    hashCache = new HashCache();
    // TODO: this can be private, we leave it public for now because tsc complains that its value is never read
    lastSyncedDirectoryHash;
    /** The most recent commit ID returned by the server after a successful commit */
    lastCommitId;
    // A queue to ensure that all filesystem operations are executed one at a time
    fsOperationQueue;
    // A queue to ensure that the actual sync operations are executed one at a time
    syncOperationQueue = new OperationQueue();
    /** This will be defined if (and only if) a sync is currently pending */
    pendingSyncUp;
    lockService;
    _status = SyncServiceStatus.IDLE;
    _synchronizationStatus = SyncronizationStatus.SERVER_AHEAD;
    _tracer;
    _rpcClient;
    _logger;
    _moduleInvalidationCallback;
    constructor(config) {
        super(config.tracer);
        this.fsOperationQueue = config.fsOperationQueue;
        // Normalize path to handle Windows drive letter case
        this.appRootDirPath = normalizePath(config.appRootDirPath);
        this.applicationId = config.applicationId;
        this.branchName = config.branchName;
        this.lockService = config.lockService;
        this._tracer = config.tracer;
        this._rpcClient = config.rpcClient;
        this._logger = config.logger;
        applyErrorHandling(this, {
            downloadDirectory: { operation: "syncing directory from server" },
            uploadDirectory: { operation: "syncing directory to server" },
            hasLocalDraftChanges: { operation: "checking for local draft changes" },
            commitLocalDraftChanges: { operation: "committing local draft changes" },
        });
        const methods = [
            "downloadDirectory",
            "uploadDirectory",
            "uploadDirectoryNowIfNeeded",
            "scheduleUploadDirectory",
            "performUploadDirectory",
            "getDraftChanges",
            "getDraftDiff",
            "createDraftFile",
            "discardLocalDraftChanges",
            "commitLocalDraftChanges",
        ];
        addTracingToMethods(this, methods, this._tracer);
    }
    getLogger() {
        return this._logger;
    }
    /** The current status of the sync service, which indicates what the service is currently doing */
    get status() {
        return this._status;
    }
    set status(status) {
        this._status = status;
        const scheduledSyncTime = status === SyncServiceStatus.SCHEDULED
            ? this.pendingSyncUp?.syncTime
            : undefined;
        this.getLogger().info(`[sync-service] statusChange: ${status}, ${scheduledSyncTime}`);
        this.emit("statusChange", status, scheduledSyncTime);
    }
    /** The current synchronization status, which indicates whether the server and local directories are in sync */
    get synchronizationStatus() {
        return this._synchronizationStatus;
    }
    set synchronizationStatus(status) {
        this._synchronizationStatus = status;
        const generationNumber = status === SyncronizationStatus.IN_SYNC
            ? this.lastSyncedGenerationNumber
            : undefined;
        this.emit("synchronizationStatusChange", status, generationNumber);
    }
    /**
     * Enqueues a filesystem operation to be executed one at a time and avoiding conflicts with sync operations.
     */
    fsOperation(operation) {
        return this.fsOperationQueue.enqueue(operation);
    }
    /**
     * Sets a callback to invalidate Vite's module graph when files are created/modified.
     * This ensures that TypeScript checks and builds get fresh data after file writes.
     *
     * @param callback - Function that takes a file path (relative to app root) and invalidates the module
     */
    setModuleInvalidationCallback(callback) {
        this._moduleInvalidationCallback = callback;
    }
    /**
     * Pulls the application down from the server.
     */
    downloadDirectory() {
        this.getLogger().info("[sync-service] Synchronizating application from the server");
        return this.fsOperation(async () => {
            this.status = SyncServiceStatus.DOWNLOADING;
            try {
                const directoryHash = await this._rpcClient.call(async (rpcClient) => {
                    return await downloadApplicationDirectory({
                        rpcClient,
                        applicationId: this.applicationId,
                        branch: this.branchName,
                        localDirectoryPath: this.appRootDirPath,
                        hashCache: this.hashCache,
                    });
                });
                this.lastSyncedDirectoryHash = directoryHash;
                this.synchronizationStatus = SyncronizationStatus.IN_SYNC;
                this.getLogger().info("[sync-service] Synchronization finished");
            }
            catch (err) {
                logSyncError(this.getLogger(), err, "[sync-service] Error synchronizing application from the server");
                throw err;
            }
            finally {
                this.status = SyncServiceStatus.IDLE;
            }
        });
    }
    /**
     * Request a sync of the local application directory to the server.
     *
     * This method is asynchronous, in the sense that it does not wait for the sync to complete. It simply schedules
     * a sync to happen in the future. The returned promise resolves as soon as the method has collected enough information
     * to schedule the sync. So the caller must wait for the returned promise to resolve before performing any filesystem
     * operations.
     * This method is also idempotent.
     */
    uploadDirectory(source) {
        this.getLogger().info("[sync-service] Application updated, scheduling synchronization");
        this.synchronizationStatus = SyncronizationStatus.LOCAL_AHEAD;
        return this.fsOperation(async () => {
            // Snapshot with uncommitted state (including drafts)
            const directorySnapshot = await snapshotLocalDirectoryWithDrafts(this.appRootDirPath);
            const completedPromise = this.scheduleUploadDirectory(directorySnapshot, false, this.generationNumberSequence?.currentValue, source);
            completedPromise.then(() => {
                this.getLogger().info("[sync-service] Synchronization completed");
            }, (err) => {
                logSyncError(this.getLogger(), err, "[sync-service] error syncing directory to server");
                this.emit("error", makeServerError(err));
            });
        });
    }
    /**
     * Requests an immediate sync of the local application directory to the server, if there are any pending changes.
     * If there are no pending changes, the method returns immediately.
     *
     * @returns the dbfs hash of the most recent sync
     */
    uploadDirectoryNowIfNeeded(source) {
        if (!this.pendingSyncUp) {
            this.getLogger().info("[sync-service] No pending sync, skipping");
            return Promise.resolve({
                directoryHash: this.lastSyncedDirectoryHash || "",
                commitId: undefined,
            });
        }
        const directorySnapshot = this.pendingSyncUp.directorySnapshot;
        this.getLogger().info("[sync-service] Performing immediate sync");
        return this.fsOperation(async () => {
            const completedPromise = this.scheduleUploadDirectory(directorySnapshot, true, this.generationNumberSequence?.currentValue, source);
            const result = await completedPromise;
            this.getLogger().info("[sync-service] Synchronization completed");
            return result;
        });
    }
    scheduleUploadDirectory(directorySnapshot, performNow, generationNumber, source) {
        if (performNow && this.pendingSyncUp) {
            // There is already a sync pending, cancel it so that we can schedule a new one, to be performed immediately
            clearTimeout(this.pendingSyncUp.timeoutId);
            this.pendingSyncUp = undefined;
        }
        if (this.pendingSyncUp) {
            // There is already a sync pending, we don't need to schedule a new one, we will use the existing one but
            // we will update the directory snapshot
            this.pendingSyncUp.directorySnapshot = directorySnapshot;
            this.pendingSyncUp.generationNumber = generationNumber;
        }
        else {
            const { promise: completedPromise, resolve, reject, } = withResolvers();
            const timeoutDelayMs = performNow ? 0 : SYNC_INTERVAL_MS;
            const pendingSyncUp = {
                directorySnapshot,
                generationNumber,
                syncTime: performance.now() + timeoutDelayMs,
                timeoutId: setTimeout(() => {
                    void (async () => {
                        this.pendingSyncUp = undefined;
                        await this.syncOperationQueue.enqueue(async () => {
                            try {
                                const isLockValid = await this.lockService.checkAndUpdateLockIfPossible();
                                if (!isLockValid) {
                                    throw new Error("Lock is not valid");
                                }
                                const result = await this.performUploadDirectory(
                                // Note: we should not use `directorySnapshot` here, because it might have changed since when the sync was
                                // originally scheduled which is when we captured the value of this lexical variable
                                pendingSyncUp.directorySnapshot, pendingSyncUp.generationNumber, source);
                                this.lastSyncedDirectoryHash = result.directoryHash;
                                resolve(result);
                            }
                            catch (error) {
                                logSyncError(this.getLogger(), error, "[sync-service] Error synchronizing directory to server: " +
                                    error?.message);
                                reject(error);
                            }
                        });
                    })();
                }, timeoutDelayMs),
                completed: completedPromise,
            };
            this.pendingSyncUp = pendingSyncUp;
        }
        this.status = SyncServiceStatus.SCHEDULED;
        return this.pendingSyncUp.completed;
    }
    cancelPendingSync() {
        if (this.pendingSyncUp) {
            clearTimeout(this.pendingSyncUp.timeoutId);
            this.pendingSyncUp = undefined;
        }
        this.status = SyncServiceStatus.PAUSED;
    }
    /**
     * Uploads the local application directory to the server, retrying if the server returns a BadRequestError.
     */
    async uploadDirectoryWithRetry(rpcClient, directorySnapshot) {
        let retryCount = 0;
        while (retryCount < 3) {
            try {
                await uploadDirectorySnapshot(rpcClient, directorySnapshot, this.hashCache);
                return;
            }
            catch (error) {
                if (error instanceof BadRequestError) {
                    retryCount++;
                    logSyncError(this.getLogger(), error, "[sync-service] Error synchronizing directory and server with error: " +
                        error.message);
                    this.getLogger().warn("[sync-service] Retrying upload due to BadRequestError and clearing hash cache");
                    this.hashCache.empty();
                    await new Promise((resolve) => setTimeout(resolve, 500));
                }
                else {
                    throw error;
                }
            }
        }
    }
    /**
     * Performs the actual upload of the local application directory to the server.
     */
    async performUploadDirectory(directorySnapshot, generationNumber, source) {
        this.getLogger().info("[sync-service] performUploadDirectory starting", {
            hash: directorySnapshot.hash,
            source,
            generationNumber,
        });
        this.status = SyncServiceStatus.UPLOADING;
        try {
            await this._rpcClient.call(async (rpcClient) => {
                await this.uploadDirectoryWithRetry(rpcClient, directorySnapshot);
            });
            // Now that the directory has been uploaded, we know that all of its hashes are in the server,
            // so we can add them to the hash cache
            this.hashCache.add_directory_recursive(directorySnapshot);
            const { degradedMode, commitId } = (await this._rpcClient.call(async (rpcClient) => {
                const payload = {
                    applicationId: this.applicationId,
                    branchName: this.branchName,
                    hash: directorySnapshot.hash,
                    source: source,
                };
                return unwrapResponseDto(rpcClient.call.v3.application.liveEditDirectoryContents.set(payload));
            }));
            if (degradedMode) {
                const errMsg = "There is an issue with the application and it was put in degraded mode: " +
                    degradedMode.errors.map((e) => e.message).join("\n");
                this.getLogger().error("[sync-service] " + errMsg);
            }
            this.getLogger().info("[sync-service] performUploadDirectory completed", {
                hash: directorySnapshot.hash,
                commitId,
                hasDegradedMode: !!degradedMode,
                source,
            });
            if (!commitId) {
                this.getLogger().warn("[sync-service] No commitId returned from server - checkpoint may not have been created", {
                    hash: directorySnapshot.hash,
                    source,
                });
            }
            this.emit("degradedModeChange", degradedMode);
            this.lastSyncedGenerationNumber = generationNumber;
            this.synchronizationStatus = SyncronizationStatus.IN_SYNC;
            if (commitId) {
                this.lastCommitId = commitId;
                this.emit("commitIdChanged", commitId);
            }
            return { directoryHash: directorySnapshot.hash, commitId };
        }
        finally {
            this.status = SyncServiceStatus.IDLE;
        }
    }
    /**
     * Checks if there are any local draft changes (.sbswap.edit and .sbswap.add files).
     *
     * @returns A promise that resolves to true if any draft files are found, false otherwise.
     */
    async hasLocalDraftChanges() {
        return this.fsOperation(async () => {
            const result = await hasSwapFiles(this.appRootDirPath);
            this.getLogger().info("[sync-service] hasLocalDraftChanges check", {
                appRootDirPath: this.appRootDirPath,
                hasDraftChanges: result,
            });
            return result;
        });
    }
    /**
     * Gets information about current draft changes.
     *
     * @returns A promise that resolves to information about draft edits, additions, and deletions.
     */
    async getDraftChanges() {
        return this.fsOperation(async () => {
            return await getDraftInfo(this.appRootDirPath);
        });
    }
    /**
     * Gets a git-like diff representation of all draft changes.
     *
     * @returns A promise that resolves to a git-like diff string.
     */
    async getDraftDiff(specificPaths) {
        return this.fsOperation(async () => {
            return await generateDraftDiff(this.appRootDirPath, specificPaths);
        });
    }
    /**
     * Creates a draft edit or addition for a file.
     *
     * @param filePath - The path to the file to edit or add (relative to app root).
     * @param draftContent - The content of the draft edit or addition.
     * @returns A promise that resolves when the draft edit or addition is created.
     */
    async createDraftFile(filePath, draftContent) {
        // Normalize path for cross-platform compatibility (Windows)
        const normalizedFilePath = normalizePath(filePath);
        this.getLogger().info("[sync-service] Creating draft file", {
            filePath: normalizedFilePath,
        });
        await this.fsOperation(async () => {
            const path = await import("node:path");
            const fullPath = path.join(this.appRootDirPath, normalizedFilePath);
            const type = await createDraft(fullPath, draftContent, this.appRootDirPath);
            if (type === "edit") {
                this.getLogger().info(`[sync-service] Draft edit created for: ${normalizedFilePath}`);
            }
            else {
                this.getLogger().info(`[sync-service] Draft addition created for: ${normalizedFilePath}`);
            }
            // Invalidate Vite's module graph for this file
            if (this._moduleInvalidationCallback) {
                this._moduleInvalidationCallback(normalizedFilePath);
                this.getLogger().info(`[sync-service] Triggered Vite module invalidation for: ${normalizedFilePath}`);
            }
        });
        this.getLogger().info("[sync-service] Draft file created", {
            filePath: normalizedFilePath,
        });
        this.emit("draftStateChanged", true);
    }
    /**
     * Creates the marker file only for a draft edit or addition.
     * Compare to `createDraftFile`. This method does not actually write content to target file.
     *
     * @param filePath - The path to the file for which to create the respective marker (relative to app root).
     * @returns A promise that resolves when the draft edit or addition marker is created.
     */
    async createDraftMarkerOnly(filePath) {
        await this.fsOperation(async () => {
            const fullPath = `${this.appRootDirPath}/${filePath}`;
            await createDraftMarkerOnly(fullPath, this.appRootDirPath);
        });
        this.emit("draftStateChanged", true);
    }
    /**
     * Creates a draft deletion by backing up the file and removing it from the workspace.
     *
     * @param filePath - The path to the file to delete (relative to app root).
     * @returns A promise that resolves when the draft deletion is created.
     */
    async createDraftDelete(filePath) {
        await this.fsOperation(async () => {
            const fullPath = `${this.appRootDirPath}/${filePath}`;
            await createDraftDelete(fullPath, this.appRootDirPath);
        });
        this.emit("draftStateChanged", true);
    }
    /**
     * Discards all local draft changes by restoring original files and removing draft additions.
     *
     * @returns A promise that resolves when all draft changes have been discarded.
     */
    async discardLocalDraftChanges(source) {
        this.getLogger().info("[sync-service] Discarding local draft changes");
        await this.fsOperation(async () => {
            await discardAllDrafts(this.appRootDirPath);
            // Emit event so UI can refresh
            this.getLogger().info("[sync-service] Local draft changes discarded");
        });
        await this.uploadDirectory(source);
        await this.uploadDirectoryNowIfNeeded(source);
        this.emit("draftStateChanged", false);
    }
    /**
     * Commits all draft changes by removing .sbswap.edit and .sbswap.add markers.
     * This makes the current filesystem state the committed state.
     *
     * @returns A promise that resolves when all drafts have been committed.
     */
    async commitLocalDraftChanges(source) {
        this.getLogger().info("[sync-service] Committing local draft changes");
        await this.fsOperation(async () => {
            await commitAllDrafts(this.appRootDirPath);
            this.getLogger().info("[sync-service] Local draft changes committed");
        });
        await this.uploadDirectory(source);
        const result = await this.uploadDirectoryNowIfNeeded(source);
        this.emit("draftStateChanged", false);
        if (result.commitId) {
            return { commitId: result.commitId };
        }
        return undefined;
    }
    /**
     * Reports draft state to the server
     */
    async reportDraftStateToServer(hasDraft) {
        try {
            await this._rpcClient.call(async (rpcClient) => {
                await rpcClient.call.v3.application.draftState.set({
                    applicationId: this.applicationId,
                    branchName: this.branchName,
                    hasDraft,
                });
            });
            this.getLogger().info(`[sync-service] Reported draft state to server: hasDraft=${hasDraft}`);
        }
        catch (error) {
            logSyncError(this.getLogger(), error, "[sync-service] Failed to report draft state to server");
        }
    }
    /**
     * Clears the drafts directory without restoring or uploading.
     * Used when restoring from a checkpoint where the database provides the correct state.
     */
    async clearDraftsDirectory() {
        this.getLogger().info("[sync-service] Clearing drafts directory");
        await this.fsOperation(async () => {
            await cleanUpDraftsDirectory(this.appRootDirPath);
            this.getLogger().info("[sync-service] Drafts directory cleared");
        });
        this.emit("draftStateChanged", false);
    }
}
//# sourceMappingURL=index.js.map