/**
 * Manager for handling uploaded session recording snapshots.
 *
 * This manager handles receiving archives (zip or tar.gz, same format as
 * sb-download), extracting them to a temporary directory, and tracking the
 * current upload state.
 *
 * Directory structure:
 * /tmp/superblocks-replay/{userId}/{uploadId}/
 *   ├── main/                     <- main archive extraction
 *   │   ├── .superblocks/
 *   │   │   ├── superblocks.json
 *   │   │   └── recordings/
 *   │   │       ├── sessions.json
 *   │   │       └── {sessionId}/
 *   │   │           ├── recording.jsonl
 *   │   │           └── snapshot.zip (or snapshot.tar.gz for older snapshots)
 *   │   ├── apis/
 *   │   ├── pages/
 *   │   └── ... (rest of app structure)
 *   └── {sessionId}/             <- session snapshot extraction
 *       ├── apis/
 *       ├── pages/
 *       └── ...
 */
import { createHash } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGunzip } from "node:zlib";
import * as tar from "tar";
import yaml from "yaml";
import yauzl from "yauzl";
import { getLogger, getErrorMeta } from "../../util/logger.js";
import { sanitizeBlockIntegrations } from "./block-traversal.js";
/** Maximum age for a snapshot to be considered recent (1 day) */
const MAX_UPLOAD_AGE_MS = 24 * 60 * 60 * 1000;
/** Relative path to the snapshot restore tracking file within app root */
const TRACKING_FILE_REL_PATH = path.join(".superblocks", "snapshot-restore.json");
/**
 * Get file size in bytes, returns null if file doesn't exist.
 */
async function getFileSize(filePath) {
    try {
        const stats = await fs.stat(filePath);
        return stats.size;
    }
    catch {
        return null;
    }
}
/**
 * Read the first two bytes from a file to determine its archive format.
 */
async function readMagicBytes(filePath) {
    const handle = await fs.open(filePath, "r");
    try {
        const buf = Buffer.alloc(2);
        await handle.read(buf, 0, 2, 0);
        return buf;
    }
    finally {
        await handle.close();
    }
}
/**
 * Extract a zip archive (from a Buffer or file path) into `destDir` using yauzl.
 */
function extractZip(input, destDir) {
    return new Promise((resolve, reject) => {
        const opts = { lazyEntries: true };
        const cb = (err, zipfile) => {
            if (err || !zipfile)
                return reject(err ?? new Error("Failed to open zip"));
            zipfile.on("error", reject);
            zipfile.readEntry();
            const resolvedDestDir = path.resolve(destDir) + path.sep;
            zipfile.on("entry", (entry) => {
                const dest = path.resolve(destDir, entry.fileName);
                if (!dest.startsWith(resolvedDestDir) &&
                    dest !== path.resolve(destDir)) {
                    return reject(new Error(`Zip entry escapes target directory: ${entry.fileName}`));
                }
                if (/\/$/.test(entry.fileName)) {
                    fs.mkdir(dest, { recursive: true }).then(() => zipfile.readEntry(), reject);
                    return;
                }
                fs.mkdir(path.dirname(dest), { recursive: true }).then(() => {
                    zipfile.openReadStream(entry, (streamErr, readStream) => {
                        if (streamErr || !readStream) {
                            return reject(streamErr ?? new Error("Failed to open read stream"));
                        }
                        const writeStream = createWriteStream(dest);
                        readStream.on("end", () => zipfile.readEntry());
                        readStream.on("error", reject);
                        writeStream.on("error", reject);
                        readStream.pipe(writeStream);
                    });
                }, reject);
            });
            zipfile.on("end", resolve);
        };
        if (typeof input === "string") {
            yauzl.open(input, opts, cb);
        }
        else {
            yauzl.fromBuffer(input, opts, cb);
        }
    });
}
/**
 * Manager for uploaded session recording snapshots.
 *
 * Tracks the current upload state and handles extraction of uploaded archives
 * to a deterministic temporary directory structure.
 */
export class SnapshotManager {
    userId;
    currentUpload = null;
    baseDir;
    /**
     * Create a new SnapshotManager.
     *
     * @param userId - Current user ID, used to namespace the temp directory
     */
    constructor(userId) {
        this.userId = userId;
        // Base directory: /tmp/superblocks-replay/{userId}
        this.baseDir = path.join(os.tmpdir(), "superblocks-replay", userId);
    }
    /**
     * Get the main extraction subdirectory within an upload directory.
     * The main tar is extracted here; session extractions are siblings.
     */
    mainDir(uploadDir) {
        return path.join(uploadDir, "main");
    }
    /**
     * Scan the base directory for recent uploads.
     * Returns the most recent upload within MAX_UPLOAD_AGE_MS, or null if none found.
     */
    async findRecentUpload() {
        try {
            // Check if base directory exists and list its contents
            const entries = await fs.readdir(this.baseDir, { withFileTypes: true });
            // Find directories (each is a hash from a previous upload)
            const directories = entries.filter((e) => e.isDirectory());
            // Check each directory's stats and find the most recent one within MAX_UPLOAD_AGE_MS
            const now = Date.now();
            let mostRecent = null;
            for (const dir of directories) {
                const dirPath = path.join(this.baseDir, dir.name);
                const stats = await fs.stat(dirPath);
                const age = now - stats.mtime.getTime();
                if (age < MAX_UPLOAD_AGE_MS) {
                    if (!mostRecent || stats.mtime > mostRecent.mtime) {
                        mostRecent = { uploadId: dir.name, mtime: stats.mtime };
                    }
                }
            }
            if (mostRecent) {
                return {
                    uploadId: mostRecent.uploadId,
                    extractedDir: path.join(this.baseDir, mostRecent.uploadId),
                    uploadedAt: mostRecent.mtime,
                };
            }
            return null;
        }
        catch {
            // Directory doesn't exist or other error - no recent upload
            return null;
        }
    }
    /**
     * Check if there's a currently uploaded snapshot.
     * Checks disk for recent uploads if no in-memory state exists.
     */
    async hasUpload() {
        const upload = await this.getUpload();
        return upload !== null;
    }
    /**
     * Get the current upload state.
     * If no in-memory state exists, checks disk for recent uploads (within 1 hour).
     *
     * @returns The current upload state, or null if no upload exists
     */
    async getUpload() {
        // Return in-memory state if available
        if (this.currentUpload) {
            return this.currentUpload;
        }
        // Check disk for recent uploads
        const recentUpload = await this.findRecentUpload();
        if (recentUpload) {
            this.currentUpload = recentUpload;
            getLogger().info(`[snapshot] Restored upload from disk`, {
                uploadId: recentUpload.uploadId,
                extractedDir: recentUpload.extractedDir,
            });
        }
        return this.currentUpload;
    }
    /**
     * Get detailed information about the uploaded snapshot.
     * Reads superblocks.json for app ID and sessions.json for recording sessions.
     *
     * @returns Snapshot details, or null if no upload exists
     */
    async getDetails() {
        const upload = await this.getUpload();
        if (!upload) {
            return null;
        }
        const mainContentDir = this.mainDir(upload.extractedDir);
        // Read superblocks.json for app ID
        const superblocksJsonPath = path.join(mainContentDir, ".superblocks", "superblocks.json");
        let appId = null;
        try {
            const content = await fs.readFile(superblocksJsonPath, "utf-8");
            const config = JSON.parse(content);
            appId = config.id || null;
        }
        catch {
            // File missing or invalid
        }
        // Read sessions.json
        const sessionsPath = path.join(mainContentDir, ".superblocks", "recordings", "sessions.json");
        let sessions = [];
        try {
            const content = await fs.readFile(sessionsPath, "utf-8");
            const sessionsList = JSON.parse(content);
            // Get file sizes for each session
            sessions = await Promise.all(sessionsList.map(async (session) => {
                const sessionRecDir = path.join(mainContentDir, ".superblocks", "recordings", session.id);
                const recordingSize = await getFileSize(path.join(sessionRecDir, "recording.jsonl"));
                const snapshotSize = (await getFileSize(path.join(sessionRecDir, "snapshot.zip"))) ??
                    (await getFileSize(path.join(sessionRecDir, "snapshot.tar.gz")));
                // Check if session was previously extracted
                const { sessionDir: extractionDir } = await this.getSessionExtractionDir(upload.extractedDir, session.id);
                let meta;
                try {
                    await fs.access(extractionDir);
                    meta = await this.computeSnapshotMeta(extractionDir);
                }
                catch {
                    // Not yet extracted — leave meta undefined
                }
                return {
                    id: session.id,
                    startedAt: session.startedAt,
                    endedAt: session.endedAt,
                    startingCommitId: session.startingCommitId,
                    currentCommitId: session.currentCommitId,
                    recordingFileSize: recordingSize ?? 0,
                    snapshotFileSize: snapshotSize,
                    meta,
                };
            }));
        }
        catch {
            // Directory missing or invalid
        }
        const meta = await this.computeSnapshotMeta(mainContentDir);
        return { appId, sessions, meta };
    }
    /**
     * Upload and extract an archive (zip or tar.gz).
     *
     * This will clear any previous upload before extracting the new one.
     * The archive should be in the same format as sb-download.
     *
     * @param archiveBuffer - The archive buffer to extract (zip or tar.gz)
     * @returns The new upload state
     */
    async upload(archiveBuffer) {
        const logger = getLogger();
        // Clean up all pre-existing uploads for this user
        try {
            await fs.rm(this.baseDir, { recursive: true, force: true });
        }
        catch {
            // ignore
        }
        this.currentUpload = null;
        const uploadId = createHash("md5").update(archiveBuffer).digest("hex");
        const uploadDir = path.join(this.baseDir, uploadId);
        const mainExtractDir = this.mainDir(uploadDir);
        logger.info(`[snapshot-upload] Starting extraction to ${mainExtractDir}`, {
            uploadId,
            bufferSize: archiveBuffer.length,
        });
        try {
            await fs.mkdir(mainExtractDir, { recursive: true });
            await this.extractArchive(archiveBuffer, mainExtractDir);
            const uploadState = {
                uploadId,
                extractedDir: uploadDir,
                uploadedAt: new Date(),
            };
            this.currentUpload = uploadState;
            logger.info(`[snapshot-upload] Extraction complete`, {
                uploadId,
                extractedDir: uploadDir,
            });
            return uploadState;
        }
        catch (error) {
            // Clean up on failure
            logger.error(`[snapshot-upload] Extraction failed`, getErrorMeta(error));
            try {
                await fs.rm(uploadDir, { recursive: true, force: true });
            }
            catch (cleanupError) {
                logger.warn(`[snapshot-upload] Failed to cleanup after extraction error`, getErrorMeta(cleanupError));
            }
            throw error;
        }
    }
    /**
     * Clear the current upload and delete extracted files.
     */
    async clear() {
        if (!this.currentUpload) {
            return;
        }
        const logger = getLogger();
        const { uploadId, extractedDir } = this.currentUpload;
        logger.info(`[snapshot-upload] Clearing upload`, {
            uploadId,
            extractedDir,
        });
        try {
            await fs.rm(extractedDir, { recursive: true, force: true });
        }
        catch (error) {
            logger.warn(`[snapshot-upload] Failed to delete extracted directory`, getErrorMeta(error));
        }
        this.currentUpload = null;
    }
    /**
     * Prepare the source directory for a restore operation.
     *
     * - If no `sessionId` is provided, returns the main upload's extractedDir.
     * - If a `sessionId` is provided, extracts the session's snapshot
     *   to a temporary directory and returns that.
     * - Writes the current app's `.superblocks/superblocks.json` from `appRootDir`
     *   into the source dir so the restored tree keeps the app's identity/config.
     * - When `sanitizeIntegrations` is true and `availableIntegrationIds` is
     *   provided, replaces unrecognised integration steps with javascript stubs.
     *
     * The returned `dispose` function cleans up any temporary directory created
     * for session-based restores. It is a no-op when no temp dir was needed.
     *
     * @param uploadId - The upload ID to restore from (must match current upload)
     * @param sessionId - Optional session ID whose snapshot to extract
     * @param appRootDir - Current app root; its .superblocks/superblocks.json is copied into the source dir
     * @param availableIntegrationIds - Integration IDs available in the current environment
     * @param sanitizeIntegrations - When true, unknown integration steps are replaced with JS stubs
     * @returns The source directory and a dispose callback
     */
    async prepareRestoreSource(uploadId, sessionId, appRootDir, availableIntegrationIds, sanitizeIntegrations) {
        const logger = getLogger();
        const upload = await this.getUpload();
        if (!upload || upload.uploadId !== uploadId) {
            throw new SnapshotRestoreError("Upload not found or no longer available");
        }
        const writeSuperblocksJsonInto = async (targetDir) => {
            const sjPath = path.join(appRootDir, ".superblocks", "superblocks.json");
            let sjBuffer = null;
            try {
                sjBuffer = await fs.readFile(sjPath);
            }
            catch {
                // May not exist
            }
            if (sjBuffer) {
                const destPath = path.join(targetDir, ".superblocks", "superblocks.json");
                await fs.mkdir(path.dirname(destPath), { recursive: true });
                await fs.writeFile(destPath, new Uint8Array(sjBuffer));
            }
        };
        let sourceDir;
        // No session — use the main extraction subdirectory
        if (!sessionId) {
            sourceDir = this.mainDir(upload.extractedDir);
        }
        else {
            // Session restore — extract to deterministic dir (or reuse)
            const { sessionDir, snapshotPath } = await this.getSessionExtractionDir(upload.extractedDir, sessionId);
            try {
                await fs.access(snapshotPath);
            }
            catch {
                throw new SnapshotRestoreError(`Session snapshot not found for session "${sessionId}"`);
            }
            logger.info(`[snapshot-restore] Extracting session snapshot to ${sessionDir}`, { uploadId, sessionId });
            await this.ensureSessionExtracted(sessionDir, snapshotPath);
            sourceDir = sessionDir;
        }
        await writeSuperblocksJsonInto(sourceDir);
        // Sanitize unknown integration IDs when the caller opts in
        if (availableIntegrationIds && sanitizeIntegrations) {
            const report = await this.sanitizeSourceIntegrations(sourceDir, availableIntegrationIds);
            logger.info("[snapshot-restore] Integration sanitization", report);
        }
        return {
            sourceDir,
            dispose: async () => {
                /* no-op — cleaned up when clear() removes extractedDir */
            },
        };
    }
    /**
     * List immediate subdirectory names under `apis/` and `pages/` within a directory.
     */
    async computeSnapshotMeta(dir) {
        const listSubdirs = async (subdir) => {
            const fullPath = path.join(dir, subdir);
            try {
                const entries = await fs.readdir(fullPath, { withFileTypes: true });
                return entries
                    .filter((e) => e.isDirectory())
                    .map((e) => e.name)
                    .sort();
            }
            catch {
                return [];
            }
        };
        const [apiNames, pageNames] = await Promise.all([
            listSubdirs("apis"),
            listSubdirs("pages"),
        ]);
        return { apiNames, pageNames };
    }
    /**
     * Detect archive format from magic bytes and extract into `destDir`.
     *
     * Accepts either a Buffer (for uploaded archives) or a file path string
     * (for session snapshots on disk). Supports both zip and tar.gz formats.
     */
    async extractArchive(input, destDir) {
        const magic = typeof input === "string"
            ? await readMagicBytes(input)
            : input.subarray(0, 2);
        const isZip = magic[0] === 0x50 && magic[1] === 0x4b;
        const isGzip = magic[0] === 0x1f && magic[1] === 0x8b;
        if (isZip) {
            await extractZip(input, destDir);
        }
        else if (isGzip) {
            const stream = typeof input === "string"
                ? createReadStream(input)
                : Readable.from(input);
            await pipeline(stream, createGunzip(), tar.x({ cwd: destDir }));
        }
        else {
            throw new Error(`Unrecognized archive format (magic: 0x${magic[0]?.toString(16)}${magic[1]?.toString(16)})`);
        }
    }
    /**
     * Get the deterministic extraction directory and snapshot path for a session.
     *
     * The snapshot lives inside the main extraction; the session extraction dir
     * is a sibling of `main/` under the upload root. Prefers `snapshot.zip`,
     * falling back to `snapshot.tar.gz` for older snapshots.
     */
    async getSessionExtractionDir(uploadDir, sessionId) {
        const recDir = path.join(this.mainDir(uploadDir), ".superblocks", "recordings", sessionId);
        const zipPath = path.join(recDir, "snapshot.zip");
        try {
            await fs.access(zipPath);
            return {
                sessionDir: path.join(uploadDir, sessionId),
                snapshotPath: zipPath,
            };
        }
        catch {
            // fall through to tar.gz
        }
        return {
            sessionDir: path.join(uploadDir, sessionId),
            snapshotPath: path.join(recDir, "snapshot.tar.gz"),
        };
    }
    /**
     * Extract a session snapshot to its deterministic directory if not already extracted.
     */
    async ensureSessionExtracted(sessionDir, snapshotPath) {
        try {
            await fs.access(sessionDir);
            return; // Already extracted
        }
        catch {
            // Need to extract
        }
        await fs.mkdir(sessionDir, { recursive: true });
        await this.extractArchive(snapshotPath, sessionDir);
    }
    /**
     * Analyze a snapshot (or session snapshot) and return metadata about its contents.
     *
     * - If no `sessionId` is provided, scans the main upload's extracted directory.
     * - If a `sessionId` is provided, extracts the session's snapshot to a
     *   deterministic directory (reused by prepareRestoreSource) and scans it.
     *
     * @param uploadId - The upload ID to analyze (must match current upload)
     * @param sessionId - Optional session ID whose snapshot to analyze
     * @returns Metadata about the snapshot contents
     */
    async analyze(uploadId, sessionId) {
        const logger = getLogger();
        const upload = await this.getUpload();
        if (!upload || upload.uploadId !== uploadId) {
            throw new SnapshotRestoreError("Upload not found or no longer available");
        }
        // No session — scan the main extraction subdirectory directly
        if (!sessionId) {
            logger.info("[snapshot-analyze] Analyzing main upload", { uploadId });
            return this.computeSnapshotMeta(this.mainDir(upload.extractedDir));
        }
        // Session — extract to deterministic dir (or reuse if already extracted)
        const { sessionDir, snapshotPath } = await this.getSessionExtractionDir(upload.extractedDir, sessionId);
        try {
            await fs.access(snapshotPath);
        }
        catch {
            throw new SnapshotRestoreError(`Session snapshot not found for session "${sessionId}"`);
        }
        logger.info("[snapshot-analyze] Analyzing session snapshot", {
            uploadId,
            sessionId,
            sessionDir,
        });
        await this.ensureSessionExtracted(sessionDir, snapshotPath);
        const meta = await this.computeSnapshotMeta(sessionDir);
        logger.info("[snapshot-analyze] Analysis complete", {
            uploadId,
            sessionId,
            meta,
        });
        return meta;
    }
    /**
     * Scan the `apis/` directory within a snapshot source, compare the
     * integration IDs found in each API's blocks against a set of IDs
     * available in the current environment, and replace any unrecognised
     * integration steps with a javascript stub.
     *
     * @param sourceDir - Root of the extracted snapshot (contains `apis/`)
     * @param availableIntegrationIds - IDs present in the current environment
     * @returns A report listing the integration IDs that were sanitized
     */
    async sanitizeSourceIntegrations(sourceDir, availableIntegrationIds) {
        const logger = getLogger();
        logger.info("[snapshot-restore] sanitizeSourceIntegrations", {
            sourceDir,
        });
        const apisDir = path.join(sourceDir, "apis");
        let apiDirs = [];
        try {
            const entries = await fs.readdir(apisDir, { withFileTypes: true });
            apiDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
        }
        catch {
            // No apis/ directory — nothing to sanitize
        }
        const allSanitizedIds = new Set();
        for (const apiName of apiDirs) {
            const yamlPath = path.join(apisDir, apiName, "api.yaml");
            try {
                const content = await fs.readFile(yamlPath, "utf-8");
                const parsed = yaml.parse(content);
                const sanitizedIds = sanitizeBlockIntegrations(parsed.blocks ?? [], availableIntegrationIds);
                if (sanitizedIds.size > 0) {
                    logger.info(`[snapshot-restore] Sanitized ${sanitizedIds.size} step(s) in "${apiName}"`);
                    await fs.writeFile(yamlPath, yaml.stringify(parsed));
                    for (const id of sanitizedIds) {
                        allSanitizedIds.add(id);
                    }
                }
            }
            catch (error) {
                logger.warn(`[snapshot-restore] Failed to parse api.yaml for "${apiName}"`, getErrorMeta(error));
            }
        }
        return {
            sanitizedIntegrationIds: [...allSanitizedIds],
        };
    }
    /**
     * Restore snapshot files into `appRootDir`.
     *
     * Clears everything in `appRootDir` except node_modules, .git, build, then
     * copies the prepared source directory (which already has the correct
     * .superblocks/superblocks.json from prepareRestoreSource) into app root.
     */
    async copySnapshotToAppRoot(sourceDir, appRootDir) {
        const logger = getLogger();
        const SKIP_DIRS = new Set(["node_modules", ".git", "build"]);
        logger.info(`[snapshot-restore] Clearing app root`, { appRootDir });
        const entries = await fs.readdir(appRootDir, {
            withFileTypes: true,
        });
        for (const entry of entries) {
            if (SKIP_DIRS.has(entry.name))
                continue;
            await fs.rm(path.join(appRootDir, entry.name), {
                recursive: true,
                force: true,
            });
        }
        logger.info(`[snapshot-restore] Copying snapshot to app root`, {
            sourceDir,
            appRootDir,
        });
        await fs.cp(sourceDir, appRootDir, { recursive: true });
        logger.info(`[snapshot-restore] Copy complete`);
    }
    /**
     * Write a tracking file that records a pending snapshot restore.
     * On the next dev-server startup, `executePendingRestore` will read it
     * and perform the actual restore.
     */
    async createPendingRestore(appRootDir, uploadId, sourceDir) {
        const logger = getLogger();
        const trackingFilePath = path.join(appRootDir, TRACKING_FILE_REL_PATH);
        const tracking = {
            uploadId,
            sourceDir,
            restoredAt: null,
        };
        await fs.mkdir(path.dirname(trackingFilePath), { recursive: true });
        await fs.writeFile(trackingFilePath, JSON.stringify(tracking, null, 2));
        logger.info("[snapshot-restore] Created pending restore tracking file", {
            uploadId,
            sourceDir,
        });
    }
    /**
     * Check for a pending snapshot restore and execute it if found.
     *
     * Note: `copySnapshotToAppRoot` clears the app root, so the tracking data
     * is read into memory first and re-written with a `restoredAt` timestamp
     * after the copy.
     *
     * @returns `true` if a restore was performed, `false` otherwise
     */
    async executePendingRestore(appRootDir) {
        const logger = getLogger();
        const trackingFilePath = path.join(appRootDir, TRACKING_FILE_REL_PATH);
        let tracking;
        try {
            const raw = await fs.readFile(trackingFilePath, "utf-8");
            tracking = JSON.parse(raw);
        }
        catch {
            return false;
        }
        if (tracking.restoredAt) {
            return false;
        }
        try {
            await fs.access(tracking.sourceDir);
        }
        catch {
            logger.warn("[snapshot-restore] Source directory from tracking file no longer exists", tracking.sourceDir);
            return false;
        }
        logger.info("[snapshot-restore] Executing pending restore", {
            uploadId: tracking.uploadId,
            sourceDir: tracking.sourceDir,
        });
        await this.copySnapshotToAppRoot(tracking.sourceDir, appRootDir);
        // Re-write tracking file with timestamp (the copy above wiped it)
        const restoredAt = new Date().toLocaleString("en-US", {
            dateStyle: "long",
            timeStyle: "long",
        });
        const updatedTracking = {
            ...tracking,
            restoredAt,
        };
        await fs.mkdir(path.dirname(trackingFilePath), { recursive: true });
        await fs.writeFile(trackingFilePath, JSON.stringify(updatedTracking, null, 2));
        logger.info("[snapshot-restore] Restore complete", {
            uploadId: tracking.uploadId,
            restoredAt,
        });
        return true;
    }
}
/**
 * Error thrown when a restore operation fails due to a user-facing issue
 * (e.g. upload not found, session snapshot missing).
 */
export class SnapshotRestoreError extends Error {
    constructor(message) {
        super(message);
        this.name = "SnapshotRestoreError";
    }
}
//# sourceMappingURL=snapshot-manager.js.map