"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.ENTERPRISE_MIGRATION_ENV_KEYS = exports.ENTERPRISE_MIGRATION_SNAPSHOT_DIR_ENV = exports.ENTERPRISE_MIGRATION_PHASE_ENV = void 0;
exports.resolveEnterpriseMigrationPlan = resolveEnterpriseMigrationPlan;
exports.enterpriseDbReadAuthorityEnabled = enterpriseDbReadAuthorityEnabled;
exports.enterpriseDbWritesEnabled = enterpriseDbWritesEnabled;
exports.legacyFilesystemReadAuthorityEnabled = legacyFilesystemReadAuthorityEnabled;
exports.legacyFilesystemWritesEnabled = legacyFilesystemWritesEnabled;
exports.buildEnterpriseMigrationDryRun = buildEnterpriseMigrationDryRun;
exports.createEnterpriseMigrationSnapshot = createEnterpriseMigrationSnapshot;
exports.restoreEnterpriseMigrationSnapshot = restoreEnterpriseMigrationSnapshot;
exports.describeEnterpriseMigrationRollback = describeEnterpriseMigrationRollback;
const crypto_1 = __importDefault(require("crypto"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const config_1 = require("../config");
const enterpriseDb_1 = require("./enterpriseDb");
const enterpriseSchema_1 = require("./enterpriseSchema");
exports.ENTERPRISE_MIGRATION_PHASE_ENV = 'SMARTPERFETTO_ENTERPRISE_MIGRATION_PHASE';
exports.ENTERPRISE_MIGRATION_SNAPSHOT_DIR_ENV = 'SMARTPERFETTO_ENTERPRISE_MIGRATION_SNAPSHOT_DIR';
const DATA_DIR_ENV = 'SMARTPERFETTO_DATA_DIR';
const LOGS_DIR_ENV = 'SMARTPERFETTO_LOGS_DIR';
const PROVIDER_DATA_DIR_ENV = 'PROVIDER_DATA_DIR_OVERRIDE';
const UPLOAD_DIR_ENV = 'UPLOAD_DIR';
function parseEnterpriseMigrationPhase(value, enterpriseEnabled) {
    if (!enterpriseEnabled)
        return 'legacy';
    if (!value || value.trim().length === 0)
        return 'cutover';
    const normalized = value.trim().toLowerCase().replace(/_/g, '-');
    if (['legacy', 'off', 'filesystem'].includes(normalized))
        return 'legacy';
    if (['p-a', 'pa', 'dual', 'dualwrite', 'dual-write'].includes(normalized))
        return 'dual-write';
    if (['p-b', 'pb', 'cut-read', 'cutover', 'db', 'db-authoritative'].includes(normalized))
        return 'cutover';
    if (['p-c', 'pc', 'retire', 'retired'].includes(normalized))
        return 'retired';
    throw new Error(`Invalid ${exports.ENTERPRISE_MIGRATION_PHASE_ENV}: ${value}. Expected dual-write, cutover, or retired.`);
}
function resolveEnterpriseMigrationPlan(env = process.env) {
    const enterpriseEnabled = (0, config_1.resolveFeatureConfig)(env).enterprise;
    const phase = parseEnterpriseMigrationPhase(env[exports.ENTERPRISE_MIGRATION_PHASE_ENV], enterpriseEnabled);
    if (!enterpriseEnabled || phase === 'legacy') {
        return {
            enterpriseEnabled,
            phase: 'legacy',
            readAuthority: 'filesystem',
            writeFilesystem: true,
            writeDb: false,
            legacyReadOnly: false,
            rollback: 'disable-enterprise',
        };
    }
    if (phase === 'dual-write') {
        return {
            enterpriseEnabled: true,
            phase,
            readAuthority: 'filesystem',
            writeFilesystem: true,
            writeDb: true,
            legacyReadOnly: false,
            rollback: 'delete-db',
        };
    }
    if (phase === 'cutover') {
        return {
            enterpriseEnabled: true,
            phase,
            readAuthority: 'db',
            writeFilesystem: false,
            writeDb: true,
            legacyReadOnly: true,
            rollback: 'return-to-dual-write',
        };
    }
    return {
        enterpriseEnabled: true,
        phase: 'retired',
        readAuthority: 'db',
        writeFilesystem: false,
        writeDb: true,
        legacyReadOnly: false,
        rollback: 'restore-snapshots',
    };
}
function enterpriseDbReadAuthorityEnabled(env = process.env) {
    return resolveEnterpriseMigrationPlan(env).readAuthority === 'db';
}
function enterpriseDbWritesEnabled(env = process.env) {
    return resolveEnterpriseMigrationPlan(env).writeDb;
}
function legacyFilesystemReadAuthorityEnabled(env = process.env) {
    return resolveEnterpriseMigrationPlan(env).readAuthority === 'filesystem';
}
function legacyFilesystemWritesEnabled(env = process.env) {
    return resolveEnterpriseMigrationPlan(env).writeFilesystem;
}
function resolveUploadRoot(env) {
    return path_1.default.resolve(env[UPLOAD_DIR_ENV] || './uploads');
}
function resolveDataRoot(env) {
    const configured = env[DATA_DIR_ENV];
    return path_1.default.resolve(configured && configured.trim().length > 0 ? configured : 'data');
}
function resolveLogsRoot(env) {
    const configured = env[LOGS_DIR_ENV];
    return path_1.default.resolve(configured && configured.trim().length > 0 ? configured : 'logs');
}
function resolveProviderFile(env) {
    const providerDir = env[PROVIDER_DATA_DIR_ENV] || path_1.default.resolve(process.cwd(), 'data');
    return path_1.default.join(path_1.default.resolve(providerDir), 'providers.json');
}
function defaultSnapshotRoot(env) {
    const configured = env[exports.ENTERPRISE_MIGRATION_SNAPSHOT_DIR_ENV];
    return path_1.default.resolve(configured && configured.trim().length > 0
        ? configured
        : path_1.default.join(process.cwd(), 'enterprise-migration-snapshots'));
}
function snapshotIdForDate(date) {
    return date.toISOString().replace(/[:.]/g, '-');
}
function filesystemSources(env) {
    return [
        { label: 'uploads', path: resolveUploadRoot(env), kind: 'directory' },
        { label: 'logs', path: resolveLogsRoot(env), kind: 'directory' },
        { label: 'data', path: resolveDataRoot(env), kind: 'directory' },
        { label: 'provider-file', path: resolveProviderFile(env), kind: 'file' },
    ];
}
function emptyHash() {
    return crypto_1.default.createHash('sha256').digest('hex');
}
function hashFile(filePath) {
    const hash = crypto_1.default.createHash('sha256');
    const data = fs_1.default.readFileSync(filePath);
    hash.update(data);
    return { bytes: data.byteLength, sha256: hash.digest('hex') };
}
function fingerprintPath(source, snapshotRoot) {
    const failures = [];
    if (!fs_1.default.existsSync(source.path)) {
        return {
            label: source.label,
            path: source.path,
            exists: false,
            kind: source.kind,
            fileCount: 0,
            totalBytes: 0,
            sha256: null,
            failures,
        };
    }
    const aggregate = crypto_1.default.createHash('sha256');
    let fileCount = 0;
    let totalBytes = 0;
    try {
        if (source.kind === 'file') {
            const stat = fs_1.default.statSync(source.path);
            if (!stat.isFile()) {
                throw new Error(`Expected file, got non-file path`);
            }
            const fileHash = hashFile(source.path);
            aggregate.update(path_1.default.basename(source.path));
            aggregate.update('\0');
            aggregate.update(String(fileHash.bytes));
            aggregate.update('\0');
            aggregate.update(fileHash.sha256);
            fileCount = 1;
            totalBytes = fileHash.bytes;
        }
        else {
            const files = listFiles(source.path, snapshotRoot);
            for (const file of files) {
                const rel = path_1.default.relative(source.path, file).split(path_1.default.sep).join('/');
                const fileHash = hashFile(file);
                aggregate.update(rel);
                aggregate.update('\0');
                aggregate.update(String(fileHash.bytes));
                aggregate.update('\0');
                aggregate.update(fileHash.sha256);
                aggregate.update('\0');
                fileCount += 1;
                totalBytes += fileHash.bytes;
            }
        }
    }
    catch (err) {
        failures.push(err.message);
    }
    return {
        label: source.label,
        path: source.path,
        exists: true,
        kind: source.kind,
        fileCount,
        totalBytes,
        sha256: failures.length > 0 ? null : (fileCount === 0 ? emptyHash() : aggregate.digest('hex')),
        failures,
    };
}
function isUnderPath(candidate, parent) {
    const relative = path_1.default.relative(parent, candidate);
    return relative === '' || (!relative.startsWith('..') && !path_1.default.isAbsolute(relative));
}
function listFiles(root, snapshotRoot) {
    const out = [];
    const stack = [root];
    while (stack.length > 0) {
        const current = stack.pop();
        if (isUnderPath(current, snapshotRoot))
            continue;
        const entries = fs_1.default.readdirSync(current, { withFileTypes: true })
            .sort((a, b) => a.name.localeCompare(b.name));
        for (const entry of entries) {
            const child = path_1.default.join(current, entry.name);
            if (isUnderPath(child, snapshotRoot))
                continue;
            if (entry.isDirectory()) {
                stack.push(child);
            }
            else if (entry.isFile()) {
                out.push(child);
            }
        }
    }
    return out.sort((a, b) => a.localeCompare(b));
}
function fingerprintDatabase(env) {
    const dbPath = (0, enterpriseDb_1.resolveEnterpriseDbPath)(env);
    const failures = [];
    let tableCounts = {};
    let totalRows = 0;
    try {
        const db = (0, enterpriseDb_1.openEnterpriseDb)(dbPath);
        try {
            db.pragma('wal_checkpoint(FULL)');
            for (const table of enterpriseSchema_1.ENTERPRISE_MINIMAL_SCHEMA_TABLES) {
                try {
                    const row = db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get();
                    tableCounts[table] = row.count;
                    totalRows += row.count;
                }
                catch (err) {
                    tableCounts[table] = 0;
                    failures.push(`${table}: ${err.message}`);
                }
            }
        }
        finally {
            db.close();
        }
    }
    catch (err) {
        failures.push(err.message);
        tableCounts = {};
    }
    let sha256 = null;
    if (fs_1.default.existsSync(dbPath)) {
        try {
            sha256 = hashFile(dbPath).sha256;
        }
        catch (err) {
            failures.push(err.message);
        }
    }
    return {
        path: dbPath,
        exists: fs_1.default.existsSync(dbPath),
        totalRows,
        tableCounts,
        sha256,
        failures,
    };
}
function stableJson(value) {
    return JSON.stringify(sortJson(value));
}
function sortJson(value) {
    if (Array.isArray(value))
        return value.map(sortJson);
    if (value && typeof value === 'object') {
        return Object.fromEntries(Object.entries(value)
            .sort(([a], [b]) => a.localeCompare(b))
            .map(([key, entry]) => [key, sortJson(entry)]));
    }
    return value;
}
function reportFingerprint(report) {
    return crypto_1.default.createHash('sha256').update(stableJson({
        phase: report.phase,
        filesystem: report.filesystem.map(item => ({
            label: item.label,
            path: item.path,
            exists: item.exists,
            kind: item.kind,
            fileCount: item.fileCount,
            totalBytes: item.totalBytes,
            sha256: item.sha256,
        })),
        database: {
            path: report.database.path,
            exists: report.database.exists,
            totalRows: report.database.totalRows,
            tableCounts: report.database.tableCounts,
            sha256: report.database.sha256,
        },
    })).digest('hex');
}
function buildEnterpriseMigrationDryRun(options = {}) {
    const env = options.env ?? process.env;
    const now = options.now ?? new Date();
    const snapshotRoot = options.snapshotRoot
        ? path_1.default.resolve(options.snapshotRoot)
        : defaultSnapshotRoot(env);
    const plan = resolveEnterpriseMigrationPlan(env);
    const filesystem = filesystemSources(env)
        .map(source => fingerprintPath(source, snapshotRoot));
    const database = fingerprintDatabase(env);
    const failures = [
        ...filesystem.flatMap(item => item.failures.map(failure => `${item.label}: ${failure}`)),
        ...database.failures.map(failure => `database: ${failure}`),
    ];
    const reportWithoutFingerprint = {
        generatedAt: now.toISOString(),
        phase: plan.phase,
        plan,
        filesystem,
        database,
        failures,
    };
    return {
        ...reportWithoutFingerprint,
        fingerprint: reportFingerprint(reportWithoutFingerprint),
    };
}
function copySnapshotSource(source, snapshotDir) {
    const snapshotPath = path_1.default.join(snapshotDir, 'filesystem', source.label);
    if (!fs_1.default.existsSync(source.path)) {
        return {
            label: source.label,
            sourcePath: source.path,
            snapshotPath,
            kind: source.kind,
            copied: false,
        };
    }
    fs_1.default.mkdirSync(path_1.default.dirname(snapshotPath), { recursive: true });
    if (source.kind === 'file') {
        fs_1.default.copyFileSync(source.path, snapshotPath);
    }
    else {
        fs_1.default.cpSync(source.path, snapshotPath, {
            recursive: true,
            force: true,
            filter: candidate => !isUnderPath(candidate, snapshotDir),
        });
    }
    return {
        label: source.label,
        sourcePath: source.path,
        snapshotPath,
        kind: source.kind,
        copied: true,
    };
}
async function backupDatabase(env, snapshotDir) {
    const sourcePath = (0, enterpriseDb_1.resolveEnterpriseDbPath)(env);
    const snapshotPath = path_1.default.join(snapshotDir, 'database', path_1.default.basename(sourcePath));
    fs_1.default.mkdirSync(path_1.default.dirname(snapshotPath), { recursive: true });
    const db = (0, enterpriseDb_1.openEnterpriseDb)(sourcePath);
    try {
        db.pragma('wal_checkpoint(FULL)');
        await db.backup(snapshotPath);
    }
    finally {
        db.close();
    }
    return {
        sourcePath,
        snapshotPath,
        copied: fs_1.default.existsSync(snapshotPath),
    };
}
async function createEnterpriseMigrationSnapshot(options = {}) {
    const env = options.env ?? process.env;
    const now = options.now ?? new Date();
    const snapshotId = options.snapshotId ?? snapshotIdForDate(now);
    const snapshotRoot = options.snapshotRoot
        ? path_1.default.resolve(options.snapshotRoot)
        : defaultSnapshotRoot(env);
    const snapshotDir = path_1.default.join(snapshotRoot, snapshotId);
    fs_1.default.mkdirSync(snapshotDir, { recursive: true });
    const dryRun = buildEnterpriseMigrationDryRun({
        env,
        now,
        snapshotRoot,
        snapshotId,
    });
    const filesystemSnapshots = filesystemSources(env)
        .map(source => copySnapshotSource(source, snapshotDir));
    const databaseSnapshot = await backupDatabase(env, snapshotDir);
    const manifest = {
        ...dryRun,
        snapshotId,
        snapshotDir,
        filesystemSnapshots,
        databaseSnapshot,
    };
    fs_1.default.writeFileSync(path_1.default.join(snapshotDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
    return manifest;
}
function loadSnapshotManifest(snapshotDir) {
    const manifestPath = path_1.default.join(snapshotDir, 'manifest.json');
    const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
    if (!manifest.snapshotId || !Array.isArray(manifest.filesystemSnapshots)) {
        throw new Error(`Invalid migration snapshot manifest: ${manifestPath}`);
    }
    return manifest;
}
function assertRestoreTargetSafe(targetPath) {
    const resolved = path_1.default.resolve(targetPath);
    const parsed = path_1.default.parse(resolved);
    if (resolved === parsed.root || resolved.length < parsed.root.length + 2) {
        throw new Error(`Refusing to restore over unsafe path: ${targetPath}`);
    }
}
async function restoreEnterpriseMigrationSnapshot(snapshotDir, options = {}) {
    const manifest = loadSnapshotManifest(path_1.default.resolve(snapshotDir));
    const restoredFilesystem = [];
    for (const item of manifest.filesystemSnapshots) {
        if (!item.copied || !fs_1.default.existsSync(item.snapshotPath)) {
            restoredFilesystem.push({
                label: item.label,
                targetPath: item.sourcePath,
                restored: false,
            });
            continue;
        }
        assertRestoreTargetSafe(item.sourcePath);
        fs_1.default.rmSync(item.sourcePath, { recursive: true, force: true });
        fs_1.default.mkdirSync(path_1.default.dirname(item.sourcePath), { recursive: true });
        if (item.kind === 'file') {
            fs_1.default.copyFileSync(item.snapshotPath, item.sourcePath);
        }
        else {
            fs_1.default.cpSync(item.snapshotPath, item.sourcePath, { recursive: true, force: true });
        }
        restoredFilesystem.push({
            label: item.label,
            targetPath: item.sourcePath,
            restored: true,
        });
    }
    const env = options.env ?? process.env;
    const targetDbPath = (0, enterpriseDb_1.resolveEnterpriseDbPath)(env);
    let restoredDb = false;
    if (manifest.databaseSnapshot.copied && fs_1.default.existsSync(manifest.databaseSnapshot.snapshotPath)) {
        assertRestoreTargetSafe(targetDbPath);
        fs_1.default.mkdirSync(path_1.default.dirname(targetDbPath), { recursive: true });
        fs_1.default.rmSync(targetDbPath, { force: true });
        fs_1.default.rmSync(`${targetDbPath}-wal`, { force: true });
        fs_1.default.rmSync(`${targetDbPath}-shm`, { force: true });
        fs_1.default.copyFileSync(manifest.databaseSnapshot.snapshotPath, targetDbPath);
        restoredDb = true;
    }
    return {
        snapshotId: manifest.snapshotId,
        restoredAt: (options.now ?? new Date()).toISOString(),
        restoredFilesystem,
        restoredDatabase: {
            targetPath: targetDbPath,
            restored: restoredDb,
        },
    };
}
function describeEnterpriseMigrationRollback(env = process.env) {
    const plan = resolveEnterpriseMigrationPlan(env);
    if (!plan.enterpriseEnabled) {
        return `Enterprise mode is disabled via ${config_1.ENTERPRISE_FEATURE_FLAG_ENV}; legacy filesystem storage is authoritative.`;
    }
    if (plan.phase === 'dual-write') {
        return 'P-A rollback: stop enterprise mode or delete the SQLite DB; legacy filesystem data remains authoritative.';
    }
    if (plan.phase === 'cutover') {
        return `P-B rollback: set ${exports.ENTERPRISE_MIGRATION_PHASE_ENV}=dual-write; keep the DB snapshot for investigation.`;
    }
    return 'P-C rollback: restore the pre-retirement filesystem snapshot and SQLite DB snapshot; reverse conversion is not promised.';
}
exports.ENTERPRISE_MIGRATION_ENV_KEYS = [
    config_1.ENTERPRISE_FEATURE_FLAG_ENV,
    exports.ENTERPRISE_MIGRATION_PHASE_ENV,
    exports.ENTERPRISE_MIGRATION_SNAPSHOT_DIR_ENV,
    enterpriseDb_1.ENTERPRISE_DB_PATH_ENV,
    DATA_DIR_ENV,
    LOGS_DIR_ENV,
    PROVIDER_DATA_DIR_ENV,
    UPLOAD_DIR_ENV,
];
//# sourceMappingURL=enterpriseMigration.js.map