/**
 * File-based storage implementation for recording entries and session index.
 *
 * Stores recording entries in JSONL files organized by session:
 * {recordingsDir}/{sessionId}/recording.jsonl
 *
 * Maintains a session index at:
 * {recordingsDir}/sessions.json
 *
 * Uses buffered writes for performance - entries are added to a buffer
 * and flushed to disk asynchronously.
 */
import { createWriteStream, promises as fs } from "node:fs";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import archiver from "archiver";
import { codeModeDownloadExcludedDirectories, codeModeExcludedFiles, } from "../../../file-system-helpers.js";
import { getErrorMeta, getLogger } from "../../../util/logger.js";
const RECORDING_FILENAME = "recording.jsonl";
const SESSIONS_FILENAME = "sessions.json";
const SNAPSHOT_FILENAME = "snapshot.zip";
/**
 * Patterns to exclude from snapshot - reuses standard exclusions.
 */
const SNAPSHOT_EXCLUDED_PATTERNS = Array.from(new Set([...codeModeDownloadExcludedDirectories, ...codeModeExcludedFiles]));
/**
 * Paths within .superblocks that should be included in snapshots.
 * Everything else in .superblocks will be excluded.
 */
const SUPERBLOCKS_WHITELIST = ["context"];
/**
 * Check if a path should be included in the snapshot.
 * Handles special logic for .superblocks directory:
 * - .superblocks itself is allowed (so the walker can descend into it)
 * - .superblocks/context and its contents are allowed
 * - Other .superblocks/* paths are excluded
 */
function shouldIncludeInSnapshot(relativePath, pathParts) {
    // Special handling for .superblocks directory
    if (relativePath === ".superblocks" || pathParts[0] === ".superblocks") {
        // Allow .superblocks directory itself
        if (relativePath === ".superblocks") {
            return true;
        }
        // For paths inside .superblocks, check whitelist
        // pathParts[1] would be the subdirectory (e.g., "context", "recordings")
        const subdir = pathParts[1];
        if (subdir !== undefined) {
            return SUPERBLOCKS_WHITELIST.includes(subdir);
        }
        // Shouldn't reach here, but exclude unknown .superblocks paths
        return false;
    }
    // Check standard exclusion patterns
    return !SNAPSHOT_EXCLUDED_PATTERNS.some((pattern) => pathParts.includes(pattern) || relativePath.startsWith(pattern + "/"));
}
/** Debounce delay for session index saves (ms) */
const SESSION_INDEX_SAVE_DEBOUNCE_MS = 1000;
/** Default max file size for snapshot inclusion (1MB) */
const DEFAULT_SNAPSHOT_MAX_FILE_SIZE_BYTES = 1048576;
/**
 * File-based storage for recording entries and session index.
 *
 * Entries are stored in JSONL format (one JSON object per line) in
 * session-specific directories. The session index is stored as a
 * JSON array that gets overwritten on each update.
 *
 * Session index saves are debounced to avoid excessive disk writes
 * when multiple updates occur in rapid succession.
 */
export class SessionRecordingStorage {
    recordingsDir;
    buffer = [];
    flushPromise = null;
    currentSessionId = null;
    storageInitialized = false;
    sessions = [];
    sessionsLoaded = false;
    loadSessionIndexPromise = null;
    sessionIndexSaveTimeout = null;
    sessionIndexDirty = false;
    constructor(recordingsDir) {
        this.recordingsDir = recordingsDir;
    }
    /**
     * Get the path to a session's directory.
     */
    getSessionDir(sessionId) {
        return path.join(this.recordingsDir, sessionId);
    }
    /**
     * Get the path to a session's recording file.
     */
    getSessionFilePath(sessionId) {
        return path.join(this.recordingsDir, sessionId, RECORDING_FILENAME);
    }
    /**
     * Get the path to the sessions index file.
     */
    getSessionsIndexPath() {
        return path.join(this.recordingsDir, SESSIONS_FILENAME);
    }
    /**
     * Ensures the storage directory exists for the current session.
     * Creates directory if needed.
     */
    async ensureStorageExists(sessionId) {
        if (this.storageInitialized && this.currentSessionId === sessionId) {
            return;
        }
        const dir = this.getSessionDir(sessionId);
        await fs.mkdir(dir, { recursive: true });
        this.currentSessionId = sessionId;
        this.storageInitialized = true;
    }
    /**
     * Ensures the recordings directory exists.
     */
    async ensureRecordingsDirExists() {
        await fs.mkdir(this.recordingsDir, { recursive: true });
    }
    /**
     * Load the session index from disk.
     * Uses a promise to prevent concurrent loads from racing.
     */
    async loadSessionIndex() {
        if (this.sessionsLoaded) {
            return;
        }
        // If already loading, wait for that to complete
        if (this.loadSessionIndexPromise) {
            await this.loadSessionIndexPromise;
            return;
        }
        // Start loading and store the promise
        this.loadSessionIndexPromise = this.doLoadSessionIndex();
        await this.loadSessionIndexPromise;
    }
    /**
     * Actually load the session index from disk.
     */
    async doLoadSessionIndex() {
        try {
            await this.ensureRecordingsDirExists();
            const filePath = this.getSessionsIndexPath();
            const content = await fs.readFile(filePath, "utf-8");
            this.sessions = JSON.parse(content);
        }
        catch {
            // File doesn't exist or is invalid, start with empty array
            this.sessions = [];
        }
        this.sessionsLoaded = true;
    }
    /**
     * Save the session index to disk immediately.
     * Clears any pending debounced save.
     */
    async saveSessionIndexNow() {
        // Clear any pending debounced save
        if (this.sessionIndexSaveTimeout) {
            clearTimeout(this.sessionIndexSaveTimeout);
            this.sessionIndexSaveTimeout = null;
        }
        this.sessionIndexDirty = false;
        try {
            await this.ensureRecordingsDirExists();
            const filePath = this.getSessionsIndexPath();
            const content = JSON.stringify(this.sessions, null, 2);
            await fs.writeFile(filePath, content, "utf-8");
        }
        catch (error) {
            getLogger().error("[recording] Failed to save session index", getErrorMeta(error));
        }
    }
    /**
     * Schedule a debounced save of the session index.
     * Multiple calls within the debounce window will only trigger one save.
     */
    scheduleSaveSessionIndex() {
        this.sessionIndexDirty = true;
        // Clear existing timeout if any
        if (this.sessionIndexSaveTimeout) {
            clearTimeout(this.sessionIndexSaveTimeout);
        }
        // Schedule new save
        this.sessionIndexSaveTimeout = setTimeout(() => {
            this.sessionIndexSaveTimeout = null;
            void this.saveSessionIndexNow();
        }, SESSION_INDEX_SAVE_DEBOUNCE_MS);
    }
    /**
     * Add a new session to the index.
     * Saves immediately since new sessions should be persisted right away.
     */
    async addSession(session) {
        await this.loadSessionIndex();
        this.sessions.push(session);
        await this.saveSessionIndexNow();
    }
    /**
     * Update an existing session in the index.
     * Uses debounced save to batch rapid updates.
     */
    async updateSession(session) {
        await this.loadSessionIndex();
        const index = this.sessions.findIndex((s) => s.id === session.id);
        if (index === -1) {
            // Session not found, add it
            this.sessions.push(session);
        }
        else {
            // Update the session
            this.sessions[index] = session;
        }
        this.scheduleSaveSessionIndex();
    }
    /**
     * Get a session by ID.
     * Returns undefined if not found.
     */
    async getSession(sessionId) {
        await this.loadSessionIndex();
        return this.sessions.find((s) => s.id === sessionId);
    }
    /**
     * List all sessions from the index.
     * Returns sessions in chronological order (oldest first).
     */
    async listSessions() {
        await this.loadSessionIndex();
        return [...this.sessions];
    }
    /**
     * Get the size of a session's recording file in bytes.
     * Returns 0 if the file doesn't exist yet.
     */
    async getSessionFileSize(sessionId) {
        try {
            const filePath = this.getSessionFilePath(sessionId);
            const stats = await fs.stat(filePath);
            return stats.size;
        }
        catch {
            return 0; // File doesn't exist yet
        }
    }
    /**
     * Delete oldest sessions to keep only the specified number.
     * Sessions are stored oldest-first, so we delete from the beginning.
     */
    async deleteOldestSessions(keepCount) {
        await this.loadSessionIndex();
        if (this.sessions.length <= keepCount)
            return;
        // Sessions are stored oldest-first, delete from the beginning
        const toDelete = this.sessions.slice(0, this.sessions.length - keepCount);
        for (const session of toDelete) {
            await this.clear(session.id);
        }
        // Update in-memory index
        this.sessions = this.sessions.slice(-keepCount);
        await this.saveSessionIndexNow();
        getLogger().info(`[recording] Deleted ${toDelete.length} old sessions, keeping ${keepCount}`);
    }
    /**
     * Add a recording entry for the given session.
     * Appends to buffer and schedules async flush.
     */
    add(sessionId, entry) {
        this.buffer.push(entry);
        this.scheduleFlush(sessionId);
    }
    /**
     * Schedule a flush of the buffer to disk.
     * Uses a simple debounce - if a flush is already pending, don't schedule another.
     */
    scheduleFlush(sessionId) {
        if (this.flushPromise)
            return;
        this.flushPromise = this.flush(sessionId).finally(() => {
            this.flushPromise = null;
        });
    }
    /**
     * Flush all buffered entries to disk.
     */
    async flush(sessionId) {
        if (this.buffer.length === 0)
            return;
        // Take the current buffer and reset it
        const entries = this.buffer;
        this.buffer = [];
        try {
            await this.ensureStorageExists(sessionId);
            const filePath = this.getSessionFilePath(sessionId);
            const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
            await fs.appendFile(filePath, lines, "utf-8");
        }
        catch (error) {
            getLogger().error(`[recording] Failed to write to ${sessionId}`, getErrorMeta(error));
            // Re-add failed entries to buffer for retry
            this.buffer.push(...entries);
        }
    }
    /**
     * Create a zip snapshot of the app directory for a session.
     * This captures the state of the codebase when the session started.
     *
     * @param sessionId - The session ID to create the snapshot for
     * @param appRootDirPath - The root directory of the application
     * @param maxFileSizeBytes - Max file size to include (default 1MB)
     * @returns Information about skipped files
     */
    async createSnapshot(sessionId, appRootDirPath, maxFileSizeBytes = DEFAULT_SNAPSHOT_MAX_FILE_SIZE_BYTES) {
        await this.ensureStorageExists(sessionId);
        const snapshotPath = path.join(this.getSessionDir(sessionId), SNAPSHOT_FILENAME);
        const skippedFiles = [];
        const archive = archiver("zip", { zlib: { level: 9 } });
        archive.on("error", (err) => {
            getLogger().error("[recording] Archive error", getErrorMeta(err));
        });
        const files = await this.walkDirectory(appRootDirPath, appRootDirPath);
        for (const { relativePath, absolutePath } of files) {
            const pathParts = relativePath.split("/");
            if (!shouldIncludeInSnapshot(relativePath, pathParts)) {
                continue;
            }
            const stat = await fs.stat(absolutePath);
            if (stat.size > maxFileSizeBytes) {
                skippedFiles.push({ path: relativePath, size: stat.size });
                continue;
            }
            archive.file(absolutePath, { name: relativePath });
        }
        const writeStream = createWriteStream(snapshotPath);
        void archive.finalize();
        await pipeline(archive, writeStream);
        return { skippedFiles };
    }
    /**
     * Recursively walk a directory and return all file paths.
     */
    async walkDirectory(dir, rootDir) {
        const results = [];
        const entries = await fs.readdir(dir, { withFileTypes: true });
        for (const entry of entries) {
            const absolutePath = path.join(dir, entry.name);
            const relativePath = path.relative(rootDir, absolutePath);
            if (entry.isDirectory()) {
                const pathParts = relativePath.split("/");
                if (!shouldIncludeInSnapshot(relativePath, pathParts)) {
                    continue;
                }
                const subResults = await this.walkDirectory(absolutePath, rootDir);
                results.push(...subResults);
            }
            else if (entry.isFile()) {
                results.push({ relativePath, absolutePath });
            }
        }
        return results;
    }
    /**
     * Clear all entries for a specific session or all sessions.
     */
    async clear(sessionId) {
        if (sessionId) {
            // Clear specific session
            const sessionDir = this.getSessionDir(sessionId);
            try {
                await fs.rm(sessionDir, { recursive: true, force: true });
                if (this.currentSessionId === sessionId) {
                    this.storageInitialized = false;
                }
            }
            catch (error) {
                getLogger().error(`[recording] Failed to clear ${sessionId}`, getErrorMeta(error));
            }
            this.buffer = [];
        }
        else {
            // Clear all
            try {
                await fs.rm(this.recordingsDir, { recursive: true, force: true });
                this.storageInitialized = false;
                this.currentSessionId = null;
                // Also clear the session index
                this.sessions = [];
                this.sessionsLoaded = false;
            }
            catch (error) {
                getLogger().error("[recording] Failed to clear all recordings", getErrorMeta(error));
            }
            this.buffer = [];
        }
    }
}
//# sourceMappingURL=session-recording-storage.js.map