import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { getLogger } from "../../../util/logger.js";
/**
 * Local filesystem storage backend for integration metadata.
 *
 * Metadata is stored as gron-formatted files for grep-ability:
 * {rootDir}/.superblocks/integration_cache/{sessionId}/{integrationId}.gron
 *
 * Features:
 * - Per-session isolation (each server instance gets unique session ID)
 * - Automatic cleanup of old sessions on startup
 * - Periodic cleanup of inactive files (15 min TTL based on access time)
 * - Touch-on-access to keep active files alive (updates atime only, preserves mtime)
 * - Configurable size limits with automatic eviction/truncation
 */
export class LocalMetadataStorage {
    SUPERBLOCKS_SYSTEM_DIR = ".superblocks";
    CACHE_DIR = "integration_cache";
    INACTIVITY_TTL_MS = 15 * 60 * 1000; // 15 minutes
    CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
    rootDir;
    maxSingleFileMB;
    maxTotalCacheMB;
    sessionId;
    cleanupInterval;
    constructor(config) {
        this.rootDir = config.rootDir;
        this.maxTotalCacheMB = config.maxTotalCacheMB ?? 300;
        this.maxSingleFileMB = config.maxSingleFileMB ?? 150;
        // Generate unique session ID for this server instance
        this.sessionId = `session-${Date.now()}-${randomBytes(4).toString("hex")}`;
    }
    async initialize() {
        const logger = getLogger();
        const sessionDir = this.getSessionDir();
        logger.info(`[metadata-storage] Initializing session: ${this.sessionId}`);
        logger.info(`[metadata-storage] Cache directory: ${sessionDir}`);
        logger.info(`[metadata-storage] Cache limits: max total=${this.maxTotalCacheMB}MB, max file=${this.maxSingleFileMB}MB`);
        // Clean up all old sessions (everything except current)
        await this.cleanupOldSessions();
        // Ensure our session directory exists
        await fs.mkdir(sessionDir, { recursive: true });
        // Start periodic cleanup of inactive files
        this.startPeriodicCleanup();
        logger.info(`[metadata-storage] Initialized with ${this.INACTIVITY_TTL_MS / 1000}s inactivity TTL`);
    }
    async saveMetadata(integrationId, metadata) {
        const gronPath = this.getGronPath(integrationId);
        const logger = getLogger();
        logger.debug(`[metadata-storage] Writing cache for integration ${integrationId}`);
        await this.ensureSessionDirExists();
        // Enforce max total cache size (delete old files if needed)
        await this.enforceMaxCacheSize();
        // Check JSON size and log if it's large
        const jsonString = JSON.stringify(metadata);
        const jsonSizeMB = Buffer.byteLength(jsonString) / (1024 * 1024);
        logger.debug(`[metadata-storage] Writing ${jsonSizeMB.toFixed(1)}MB JSON for ${integrationId}`);
        if (jsonSizeMB > this.maxSingleFileMB) {
            logger.warn(`[metadata-storage] JSON for ${integrationId} is ${jsonSizeMB.toFixed(1)}MB, ` +
                `exceeds ${this.maxSingleFileMB}MB limit. ` +
                `Metadata may have been truncated but is still large.`);
        }
        // Spawn gron process
        const gron = spawn("gron", [], {
            stdio: ["pipe", "pipe", "inherit"],
        });
        // Set up error/exit handlers first
        const exitPromise = new Promise((resolve, reject) => {
            gron.on("exit", (code) => {
                if (code === null)
                    code = 0;
                resolve(code);
            });
            gron.on("error", (err) => {
                reject(new Error(`Failed to spawn gron: ${err.message}. Is gron installed?`));
            });
        });
        // Write JSON to gron's stdin
        logger.debug(`[metadata-storage] Writing ${jsonString.length} bytes of JSON to gron`);
        gron.stdin.write(jsonString);
        gron.stdin.end();
        // Collect gron output with timeout
        const chunks = [];
        const timeout = setTimeout(() => {
            logger.error(`[metadata-storage] Timeout waiting for gron to process ${integrationId}`);
            gron.kill();
        }, 30000); // 30 second timeout
        try {
            for await (const chunk of gron.stdout) {
                chunks.push(chunk);
            }
            clearTimeout(timeout);
            let gronOutput = Buffer.concat(chunks);
            logger.debug(`[metadata-storage] Collected ${gronOutput.length} bytes from gron`);
            // Wait for gron to exit
            const exitCode = await exitPromise;
            if (exitCode !== 0) {
                throw new Error(`gron exited with code ${exitCode}`);
            }
            // Truncate gron output if it exceeds max file size
            const maxFileSizeBytes = this.maxSingleFileMB * 1024 * 1024;
            const gronSizeMB = gronOutput.length / (1024 * 1024);
            if (gronOutput.length > maxFileSizeBytes) {
                const originalSizeBytes = gronOutput.length;
                gronOutput = gronOutput.slice(0, maxFileSizeBytes);
                const truncatedLines = gronOutput.toString("utf-8").split("\n").length;
                // Estimate original lines from size ratio
                const estimatedOriginalLines = Math.round(truncatedLines * (originalSizeBytes / maxFileSizeBytes));
                logger.warn(`[metadata-storage] Gron output for ${integrationId} truncated: ` +
                    `${gronSizeMB.toFixed(1)}MB → ${this.maxSingleFileMB}MB ` +
                    `(~${estimatedOriginalLines} → ${truncatedLines} lines). ` +
                    `Some metadata will not be searchable.`);
            }
            // Write gron output to file
            await fs.writeFile(gronPath, gronOutput);
            logger.debug(`[metadata-storage] Wrote ${gronOutput.length} bytes for ${integrationId}`);
        }
        catch (err) {
            clearTimeout(timeout);
            throw err;
        }
    }
    async loadMetadata(integrationId) {
        const gronPath = this.getGronPath(integrationId);
        try {
            await fs.access(gronPath);
            // Touch file to update access time (keep it alive)
            await this.touchFile(gronPath);
            getLogger().debug(`[metadata-storage] Cache hit for ${integrationId} at ${gronPath}`);
            return gronPath;
        }
        catch {
            getLogger().debug(`[metadata-storage] Failed to load from cache for ${integrationId}`);
            return null;
        }
    }
    async getAge(integrationId) {
        const gronPath = this.getGronPath(integrationId);
        try {
            const stats = await fs.stat(gronPath);
            // Use mtime (modification time) to determine age
            // mtime is updated when file is written (saveMetadata)
            const ageMs = Date.now() - stats.mtimeMs;
            return ageMs;
        }
        catch {
            return null;
        }
    }
    async invalidate(integrationId) {
        const gronPath = this.getGronPath(integrationId);
        try {
            await fs.unlink(gronPath);
            getLogger().debug(`[metadata-storage] Invalidated cache for ${integrationId}`);
        }
        catch (err) {
            if (err.code !== "ENOENT") {
                getLogger().warn(`[metadata-storage] Failed to invalidate cache for ${integrationId}: ${err.message}`);
            }
            // If file doesn't exist, that's fine - it's already invalidated
        }
    }
    async cleanup() {
        const logger = getLogger();
        // Stop periodic cleanup
        if (this.cleanupInterval) {
            clearInterval(this.cleanupInterval);
            this.cleanupInterval = undefined;
        }
        // Delete our session directory
        try {
            await fs.rm(this.getSessionDir(), { recursive: true, force: true });
            logger.info(`[metadata-storage] Cleaned up session ${this.sessionId} on shutdown`);
        }
        catch (err) {
            logger.warn(`[metadata-storage] Failed to cleanup session: ${err}`);
        }
    }
    startPeriodicCleanup() {
        const logger = getLogger();
        logger.debug(`[metadata-storage] Starting periodic cleanup (every ${this.CLEANUP_INTERVAL_MS / 1000}s)`);
        // Run cleanup every 5 minutes
        this.cleanupInterval = setInterval(() => {
            void this.cleanupInactiveFiles();
        }, this.CLEANUP_INTERVAL_MS);
        // Don't let this interval keep the process alive
        this.cleanupInterval.unref();
    }
    async cleanupInactiveFiles() {
        const logger = getLogger();
        const sessionDir = this.getSessionDir();
        try {
            const files = await fs.readdir(sessionDir);
            const now = Date.now();
            let deletedCount = 0;
            for (const file of files) {
                if (!file.endsWith(".gron"))
                    continue;
                const filePath = path.join(sessionDir, file);
                const stats = await fs.stat(filePath);
                const inactiveTime = now - stats.atimeMs;
                // Delete if inactive for > 15 minutes
                if (inactiveTime > this.INACTIVITY_TTL_MS) {
                    await fs.unlink(filePath);
                    deletedCount++;
                    logger.debug(`[metadata-storage] Deleted inactive cache file: ${file} (inactive for ${Math.round(inactiveTime / 1000)}s)`);
                }
            }
            if (deletedCount > 0) {
                logger.info(`[metadata-storage] Cleaned up ${deletedCount} inactive cache files`);
            }
        }
        catch (err) {
            if (err.code !== "ENOENT") {
                logger.warn(`[metadata-storage] Error during inactive file cleanup: ${err}`);
            }
        }
    }
    /**
     * Calculate total disk usage of the current session's cache.
     */
    async getTotalCacheSizeMB() {
        const sessionDir = this.getSessionDir();
        let totalBytes = 0;
        try {
            const files = await fs.readdir(sessionDir);
            for (const file of files) {
                if (!file.endsWith(".gron"))
                    continue;
                const stats = await fs.stat(path.join(sessionDir, file));
                totalBytes += stats.size;
            }
        }
        catch (err) {
            if (err.code !== "ENOENT") {
                getLogger().warn(`[metadata-storage] Error calculating cache size: ${err}`);
            }
        }
        return totalBytes / (1024 * 1024);
    }
    /**
     * Ensure we have room in the cache. If over limit, delete oldest files.
     */
    async enforceMaxCacheSize() {
        const currentSizeMB = await this.getTotalCacheSizeMB();
        if (currentSizeMB <= this.maxTotalCacheMB) {
            return; // Under limit, all good
        }
        const logger = getLogger();
        const sessionDir = this.getSessionDir();
        try {
            const files = await fs.readdir(sessionDir);
            const fileStats = await Promise.all(files
                .filter((f) => f.endsWith(".gron"))
                .map(async (file) => {
                const filePath = path.join(sessionDir, file);
                const stats = await fs.stat(filePath);
                return {
                    file,
                    path: filePath,
                    atime: stats.atimeMs,
                    sizeMB: stats.size / (1024 * 1024),
                };
            }));
            logger.warn(`[metadata-storage] ⚠️  CACHE SIZE LIMIT EXCEEDED: ` +
                `${currentSizeMB.toFixed(1)}MB / ${this.maxTotalCacheMB}MB ` +
                `(${fileStats.length} files). Evicting least recently used files...`);
            // Sort by oldest first (least recently used)
            fileStats.sort((a, b) => a.atime - b.atime);
            // Delete oldest files until we're under the limit
            let currentSize = currentSizeMB;
            let deletedCount = 0;
            let deletedSizeMB = 0;
            for (const { file, path: filePath, sizeMB } of fileStats) {
                if (currentSize <= this.maxTotalCacheMB)
                    break;
                await fs.unlink(filePath);
                currentSize -= sizeMB;
                deletedCount++;
                deletedSizeMB += sizeMB;
                logger.debug(`[metadata-storage] Evicted ${file} (${sizeMB.toFixed(1)}MB, last accessed ${new Date(fileStats[deletedCount - 1]?.atime || 0).toISOString()})`);
            }
            logger.warn(`[metadata-storage] ✅ Cache cleanup complete: ` +
                `evicted ${deletedCount} files (${deletedSizeMB.toFixed(1)}MB), ` +
                `${fileStats.length - deletedCount} files remaining (${currentSize.toFixed(1)}MB)`);
        }
        catch (err) {
            logger.warn(`[metadata-storage] Error enforcing cache size: ${err}`);
        }
    }
    async touchFile(filePath) {
        // Update atime (access time) only to mark as recently accessed
        const stats = await fs.stat(filePath);
        await fs.utimes(filePath, new Date(), stats.mtime);
    }
    async cleanupOldSessions() {
        const cacheBaseDir = this.getCacheBaseDir();
        const logger = getLogger();
        try {
            const entries = await fs.readdir(cacheBaseDir, { withFileTypes: true });
            let deletedCount = 0;
            for (const entry of entries) {
                if (!entry.isDirectory() || !entry.name.startsWith("session-")) {
                    continue;
                }
                // Delete all sessions except our own
                if (entry.name !== this.sessionId) {
                    const dirPath = path.join(cacheBaseDir, entry.name);
                    await fs.rm(dirPath, { recursive: true, force: true });
                    deletedCount++;
                    logger.debug(`[metadata-storage] Cleaned up old session: ${entry.name}`);
                }
            }
            if (deletedCount > 0) {
                logger.info(`[metadata-storage] Cleaned up ${deletedCount} old session(s)`);
            }
        }
        catch (err) {
            if (err.code !== "ENOENT") {
                logger.warn(`[metadata-storage] Error during session cleanup: ${err}`);
            }
            // Cache dir doesn't exist yet - that's fine
        }
    }
    getCacheBaseDir() {
        return path.join(this.rootDir, this.SUPERBLOCKS_SYSTEM_DIR, this.CACHE_DIR);
    }
    getSessionDir() {
        return path.join(this.getCacheBaseDir(), this.sessionId);
    }
    getGronPath(integrationId) {
        // Sanitize integration ID to be filesystem-safe
        const safeId = integrationId.replace(/[^a-zA-Z0-9-_]/g, "_");
        return path.join(this.getSessionDir(), `${safeId}.gron`);
    }
    async ensureSessionDirExists() {
        await fs.mkdir(this.getSessionDir(), { recursive: true });
    }
}
//# sourceMappingURL=local.js.map