"use strict";
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 Gracker (Chris)
// This file is part of SmartPerfetto. See LICENSE for details.
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileSystemSceneReportStore = void 0;
/**
 * SceneReportStore — 7-day disk cache for SceneReport JSON.
 *
 * On-disk layout
 * --------------
 *   {reportDir}/index.json           — single index, see IndexFile
 *   {reportDir}/{reportId}.json      — one file per report (atomic write)
 *
 * The index is the source of truth for `loadByHash` and `cleanupExpired`;
 * a stray .json file with no index entry is harmless and will be picked
 * up by the next save that happens to assign a colliding reportId (vanishingly
 * unlikely with uuid v4) or by future hard-size cleanup work.
 *
 * Concurrency
 * -----------
 * All mutating operations (`save`, `delete`, `cleanupExpired`) go through a
 * single-writer promise chain (`writeQueue`). Reads (`loadById`,
 * `loadByHash`) go directly to disk and may observe an older snapshot —
 * that's fine because the index is rewritten atomically via `fs.rename`,
 * so a reader either sees the pre- or post-state, never a torn one.
 *
 * Deployment assumption: this guarantee holds only for a *single* Node
 * process owning the `reportDir`. If the backend is ever scaled to multiple
 * worker processes / instances sharing the same directory, the writeQueue
 * lock disappears and concurrent writers can race on the index. A future
 * cluster deployment would need a file lock (or external coordination)
 * around `save`/`delete`/`cleanupExpired`.
 *
 * Atomicity
 * ---------
 * Each write goes to a unique `.tmp.{pid}.{ts}` file then `fs.rename` into
 * place. POSIX guarantees `rename` is atomic on the same filesystem, which
 * is exactly what we need so a crashed backend can never leave a half-
 * written index.json behind.
 *
 * Schema versioning
 * -----------------
 * `loadById` checks `report.generatedBy.pipelineVersion === 'v2'` and
 * returns null on mismatch. This means a Stage1/Stage2 contract change
 * (bump to 'v3') invalidates the cache without requiring a migration.
 */
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const atomicFileWriter_1 = require("../../utils/atomicFileWriter");
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INDEX_FILENAME = 'index.json';
const REPORT_EXT = '.json';
const SUPPORTED_PIPELINE_VERSION = 'v2';
function hashProfileKey(hash, routeProfile) {
    return `${hash}::${routeProfile}`;
}
// ---------------------------------------------------------------------------
// Implementation
// ---------------------------------------------------------------------------
class FileSystemSceneReportStore {
    constructor(reportDir) {
        this.reportDir = reportDir;
        /** Single-writer chain — every mutation `await`s the previous one. */
        this.writeQueue = Promise.resolve();
    }
    // -- path helpers ---------------------------------------------------------
    get indexPath() {
        return path_1.default.join(this.reportDir, INDEX_FILENAME);
    }
    reportPath(reportId) {
        return path_1.default.join(this.reportDir, `${reportId}${REPORT_EXT}`);
    }
    // -- low-level I/O --------------------------------------------------------
    async ensureDir() {
        await fs_1.promises.mkdir(this.reportDir, { recursive: true });
    }
    emptyIndex() {
        return { version: 1, byHash: {}, byReport: {} };
    }
    /**
     * Read+parse the index. Returns an empty index when:
     *  - The file doesn't exist (first-ever save).
     *  - The JSON is corrupted (rare, but recoverable — the next save rewrites it).
     *  - The version is unrecognised.
     *
     * Logs a warning on corruption so we notice in CI/dev.
     */
    async readIndex() {
        try {
            const raw = await fs_1.promises.readFile(this.indexPath, 'utf8');
            const parsed = JSON.parse(raw);
            if (parsed.version !== 1 ||
                typeof parsed.byHash !== 'object' || parsed.byHash === null ||
                typeof parsed.byReport !== 'object' || parsed.byReport === null) {
                console.warn('[SceneReportStore] index.json structure unexpected, resetting');
                return this.emptyIndex();
            }
            return parsed;
        }
        catch (err) {
            if (err?.code !== 'ENOENT') {
                console.warn('[SceneReportStore] index.json unreadable, resetting:', err?.message ?? err);
            }
            return this.emptyIndex();
        }
    }
    async writeIndex(index) {
        await (0, atomicFileWriter_1.atomicWriteFile)(this.indexPath, JSON.stringify(index));
    }
    /**
     * Schedule `op` after every previously enqueued mutation. The continuation
     * stored back into `writeQueue` deliberately swallows the result and any
     * error so a single failing op cannot poison every subsequent write — the
     * caller still receives the original promise (with the original rejection).
     */
    enqueue(op) {
        const next = this.writeQueue.then(() => op());
        this.writeQueue = next.then(() => undefined, () => undefined);
        return next;
    }
    // -- public API -----------------------------------------------------------
    async save(report, routeProfile = 'legacy', options = {}) {
        return this.enqueue(async () => {
            await this.ensureDir();
            const filename = `${report.reportId}${REPORT_EXT}`;
            const reportFilePath = path_1.default.join(this.reportDir, filename);
            // 1) Write the report file atomically.
            await (0, atomicFileWriter_1.atomicWriteFile)(reportFilePath, JSON.stringify(report));
            // 2) Update the index in-place.
            const index = await this.readIndex();
            // If the same hash was already pointing at a different reportId, drop
            // the stale byReport entry so future loadByHash returns the new one.
            // We deliberately don't unlink the old report file here — a concurrent
            // caller may still hold the old reportId for a loadById; the next
            // cleanupExpired sweep will pick it up via TTL.
            const indexByHash = options.indexByHash !== false;
            if (indexByHash && report.traceHash) {
                const profileKey = hashProfileKey(report.traceHash, routeProfile);
                const previousId = index.byHash[profileKey];
                if (previousId && previousId !== report.reportId) {
                    delete index.byReport[previousId];
                }
            }
            index.byReport[report.reportId] = {
                hash: report.traceHash,
                expiresAt: report.expiresAt,
                createdAt: report.createdAt,
                filename,
                routeProfile,
            };
            if (indexByHash && report.traceHash) {
                index.byHash[hashProfileKey(report.traceHash, routeProfile)] = report.reportId;
            }
            await this.writeIndex(index);
        });
    }
    async loadById(reportId) {
        try {
            const raw = await fs_1.promises.readFile(this.reportPath(reportId), 'utf8');
            const report = JSON.parse(raw);
            if (report?.generatedBy?.pipelineVersion !== SUPPORTED_PIPELINE_VERSION) {
                return null;
            }
            // TTL gate at read time. Mirrors loadByHash so a direct
            // `GET /scene-reconstruct/report/:id` call cannot return an
            // expired report between cleanup sweeps.
            if (report.expiresAt !== null && report.expiresAt < Date.now()) {
                return null;
            }
            return report;
        }
        catch (err) {
            if (err?.code !== 'ENOENT') {
                console.warn(`[SceneReportStore] loadById(${reportId}) failed:`, err?.message ?? err);
            }
            return null;
        }
    }
    async loadByHash(hash, routeProfile = 'legacy') {
        const index = await this.readIndex();
        const reportId = index.byHash[hashProfileKey(hash, routeProfile)]
            // Backward compatibility for pre-profile legacy cache entries.
            ?? (routeProfile === 'legacy' ? index.byHash[hash] : undefined);
        if (!reportId)
            return null;
        const entry = index.byReport[reportId];
        if (!entry)
            return null;
        // TTL gate at read time — expired entries return null and will be
        // garbage-collected by the next cleanupExpired sweep.
        if (entry.expiresAt !== null && entry.expiresAt < Date.now()) {
            return null;
        }
        return this.loadById(reportId);
    }
    async delete(reportId) {
        return this.enqueue(async () => {
            const index = await this.readIndex();
            const entry = index.byReport[reportId];
            if (!entry)
                return false;
            delete index.byReport[reportId];
            if (entry.hash && index.byHash[entry.hash] === reportId) {
                delete index.byHash[entry.hash];
            }
            if (entry.hash) {
                const profileKey = hashProfileKey(entry.hash, entry.routeProfile ?? 'legacy');
                if (index.byHash[profileKey] === reportId) {
                    delete index.byHash[profileKey];
                }
            }
            await this.writeIndex(index);
            try {
                await fs_1.promises.unlink(this.reportPath(reportId));
            }
            catch (err) {
                if (err?.code !== 'ENOENT') {
                    console.warn(`[SceneReportStore] failed to unlink report ${reportId}:`, err?.message ?? err);
                }
            }
            return true;
        });
    }
    async cleanupExpired(nowMs) {
        return this.enqueue(async () => {
            const index = await this.readIndex();
            const expiredIds = [];
            for (const [reportId, entry] of Object.entries(index.byReport)) {
                if (entry.expiresAt !== null && entry.expiresAt < nowMs) {
                    expiredIds.push(reportId);
                }
            }
            if (expiredIds.length === 0)
                return 0;
            for (const reportId of expiredIds) {
                const entry = index.byReport[reportId];
                delete index.byReport[reportId];
                if (entry?.hash && index.byHash[entry.hash] === reportId) {
                    delete index.byHash[entry.hash];
                }
                if (entry?.hash) {
                    const profileKey = hashProfileKey(entry.hash, entry.routeProfile ?? 'legacy');
                    if (index.byHash[profileKey] === reportId) {
                        delete index.byHash[profileKey];
                    }
                }
                try {
                    await fs_1.promises.unlink(this.reportPath(reportId));
                }
                catch (err) {
                    if (err?.code !== 'ENOENT') {
                        console.warn(`[SceneReportStore] cleanup failed to unlink ${reportId}:`, err?.message ?? err);
                    }
                }
            }
            await this.writeIndex(index);
            return expiredIds.length;
        });
    }
}
exports.FileSystemSceneReportStore = FileSystemSceneReportStore;
//# sourceMappingURL=sceneReportStore.js.map