import { randomUUID } from 'node:crypto';
import { closeSync, mkdirSync, openSync, renameSync, writeSync } from 'node:fs';
import { mkdir, open, readFile, rename } from 'node:fs/promises';
import path from 'node:path';
import { normalizeTerminalLayoutState } from '../../shared/layoutState.js';
const emptyState = () => ({
    passwordCredential: null,
    totpCredential: null,
    mainName: null,
    agents: [],
    trustedDevices: [],
    ipWhitelist: [],
    terminalLayout: null,
    agentLayouts: {},
    notificationAssets: [],
    notificationAssetDisabledIds: [],
    manualUpdate: null,
    agentManualUpdates: {}
});
function normalizeState(value) {
    if (!value || typeof value !== 'object') {
        return emptyState();
    }
    const state = value;
    const passwordCredential = state.passwordCredential &&
        typeof state.passwordCredential === 'object' &&
        state.passwordCredential.algorithm === 'scrypt' &&
        typeof state.passwordCredential.hash === 'string' &&
        typeof state.passwordCredential.salt === 'string' &&
        typeof state.passwordCredential.createdAt === 'string' &&
        typeof state.passwordCredential.updatedAt === 'string'
        ? state.passwordCredential
        : null;
    const totpCredential = state.totpCredential &&
        typeof state.totpCredential === 'object' &&
        typeof state.totpCredential.secret === 'string' &&
        state.totpCredential.algorithm === 'SHA1' &&
        state.totpCredential.digits === 6 &&
        state.totpCredential.period === 30 &&
        typeof state.totpCredential.createdAt === 'string'
        ? state.totpCredential
        : null;
    const agents = Array.isArray(state.agents)
        ? state.agents.filter((a) => a !== null &&
            typeof a === 'object' &&
            typeof a.id === 'string' &&
            typeof a.name === 'string' &&
            typeof a.url === 'string' &&
            typeof a.token === 'string' &&
            typeof a.createdAt === 'string')
        : [];
    const trustedDevices = Array.isArray(state.trustedDevices)
        ? state.trustedDevices.filter((d) => d !== null &&
            typeof d === 'object' &&
            typeof d.id === 'string' &&
            typeof d.tokenHash === 'string' &&
            typeof d.label === 'string' &&
            typeof d.createdAt === 'string' &&
            typeof d.lastSeenAt === 'string' &&
            typeof d.expiresAt === 'string')
        : [];
    const ipWhitelist = Array.isArray(state.ipWhitelist)
        ? state.ipWhitelist.filter((r) => r !== null &&
            typeof r === 'object' &&
            typeof r.id === 'string' &&
            typeof r.value === 'string' &&
            typeof r.label === 'string' &&
            typeof r.createdAt === 'string')
        : [];
    const agentLayouts = {};
    if (state.agentLayouts && typeof state.agentLayouts === 'object') {
        for (const [agentId, value] of Object.entries(state.agentLayouts)) {
            const normalized = normalizeStoredTerminalLayout(value);
            if (normalized) {
                agentLayouts[agentId] = normalized;
            }
        }
    }
    const notificationAssets = Array.isArray(state.notificationAssets)
        ? state.notificationAssets.filter((asset) => asset !== null &&
            typeof asset === 'object' &&
            typeof asset.id === 'string' &&
            typeof asset.filename === 'string' &&
            typeof asset.storedFilename === 'string' &&
            (asset.contentType === 'video/mp4' ||
                asset.contentType === 'video/webm') &&
            typeof asset.size === 'number' &&
            Number.isFinite(asset.size) &&
            asset.size >= 0 &&
            typeof asset.createdAt === 'string')
        : [];
    const notificationAssetDisabledIds = Array.isArray(state.notificationAssetDisabledIds)
        ? Array.from(new Set(state.notificationAssetDisabledIds.filter((assetId) => typeof assetId === 'string' && assetId.length > 0)))
        : [];
    // mainName: only accept non-empty strings, otherwise null so older state
    // files (pre-mainName) and operators who cleared the field both fall back
    // to the client's "Local" default.
    const rawMainName = typeof state.mainName === 'string' ? state.mainName.trim() : '';
    const mainName = rawMainName || null;
    return {
        passwordCredential,
        totpCredential,
        mainName,
        agents,
        trustedDevices,
        ipWhitelist,
        terminalLayout: normalizeStoredTerminalLayout(state.terminalLayout),
        agentLayouts,
        notificationAssets,
        notificationAssetDisabledIds,
        manualUpdate: normalizeStoredManualUpdate(state.manualUpdate),
        agentManualUpdates: normalizeAgentManualUpdates(state.agentManualUpdates)
    };
}
function normalizeAgentManualUpdates(value) {
    const normalized = {};
    if (!value || typeof value !== 'object') {
        return normalized;
    }
    for (const [agentId, manualUpdate] of Object.entries(value)) {
        const normalizedManualUpdate = normalizeStoredManualUpdate(manualUpdate);
        if (agentId && normalizedManualUpdate) {
            normalized[agentId] = normalizedManualUpdate;
        }
    }
    return normalized;
}
function normalizeStoredManualUpdate(value) {
    if (!value || typeof value !== 'object') {
        return null;
    }
    const candidate = value;
    if (candidate.reason !== 'permission' ||
        typeof candidate.message !== 'string' ||
        typeof candidate.command !== 'string' ||
        typeof candidate.detectedAt !== 'string') {
        return null;
    }
    return {
        reason: candidate.reason,
        message: candidate.message,
        command: candidate.command,
        detectedAt: candidate.detectedAt
    };
}
function normalizeStoredTerminalLayout(value) {
    if (!value || typeof value !== 'object') {
        return null;
    }
    const candidate = value;
    const layout = normalizeTerminalLayoutState(candidate.layout);
    const revision = candidate.revision;
    if (!layout || typeof revision !== 'number' || !Number.isInteger(revision) || revision < 0 || typeof candidate.updatedAt !== 'string') {
        return null;
    }
    return {
        layout,
        revision,
        updatedAt: candidate.updatedAt
    };
}
const DEFAULT_COALESCE_WINDOW_MS = 1000;
export class FileStore {
    path;
    memoryState = null;
    dirty = false;
    flushTimer = null;
    updateQueue = Promise.resolve();
    coalesceWindowMs;
    onError;
    constructor(path, options = {}) {
        this.path = path;
        this.coalesceWindowMs = options.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS;
        this.onError = options.onError ?? defaultOnError;
    }
    // Replace the error handler after construction. The Fastify logger is not
    // available when the store is built in index.ts, so we wire it in once the
    // app is constructed.
    setErrorHandler(handler) {
        this.onError = handler;
    }
    async init(options = {}) {
        const createIfMissing = options.createIfMissing ?? true;
        await mkdir(path.dirname(this.path), { recursive: true, mode: 0o700 });
        try {
            this.memoryState = deepFreeze(await this.readFromDisk());
        }
        catch (error) {
            if (error.code !== 'ENOENT') {
                throw error;
            }
            if (!createIfMissing) {
                throw error;
            }
            this.memoryState = deepFreeze(emptyState());
            await this.writeToDisk(this.memoryState);
        }
    }
    async read() {
        if (this.memoryState) {
            return this.memoryState;
        }
        const state = deepFreeze(await this.readFromDisk());
        this.memoryState = state;
        return state;
    }
    async update(updater, options = {}) {
        const mode = options.flush ?? 'coalesced';
        return this.enqueue(async () => {
            const current = this.memoryState ?? deepFreeze(await this.readFromDisk());
            const next = deepFreeze(await updater(current));
            this.memoryState = next;
            this.dirty = true;
            if (mode === 'immediate') {
                await this.flushDirty();
            }
            else {
                this.scheduleFlush();
            }
            return next;
        });
    }
    // Force any pending coalesced write to disk now.
    async flush() {
        await this.enqueue(() => this.flushDirty());
    }
    // Force the current in-memory snapshot to disk, even when the store is not
    // marked dirty, then read it back. Used before self-update/restart so the
    // running process never knowingly restarts from an unverified state file.
    async checkpoint(options = {}) {
        return this.enqueue(async () => {
            const snapshot = this.memoryState ?? deepFreeze(await this.readFromDisk());
            options.validateSnapshot?.(snapshot);
            this.cancelTimer();
            this.memoryState = snapshot;
            await this.writeToDisk(snapshot);
            this.dirty = false;
            const disk = deepFreeze(await this.readFromDisk());
            return { snapshot, disk };
        });
    }
    // Synchronous flush for shutdown handlers. Bypasses the update queue so it
    // works inside `beforeExit` and SIGTERM handlers without an extra microtask
    // tick. Safe to call after a normal async `flush()` — becomes a no-op.
    flushSync() {
        this.cancelTimer();
        if (!this.dirty || !this.memoryState) {
            return;
        }
        mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
        const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
        const fd = openSync(tempPath, 'w', 0o600);
        try {
            writeSync(fd, `${JSON.stringify(this.memoryState, null, 2)}\n`);
        }
        finally {
            closeSync(fd);
        }
        renameSync(tempPath, this.path);
        this.dirty = false;
    }
    async flushDirty() {
        this.cancelTimer();
        if (!this.dirty || !this.memoryState) {
            return;
        }
        await this.writeToDisk(this.memoryState);
        this.dirty = false;
    }
    scheduleFlush() {
        if (this.flushTimer) {
            return;
        }
        const window = Math.max(0, this.coalesceWindowMs);
        this.flushTimer = setTimeout(() => {
            this.flushTimer = null;
            this.flush().catch((err) => this.onError(err));
        }, window);
    }
    cancelTimer() {
        if (this.flushTimer) {
            clearTimeout(this.flushTimer);
            this.flushTimer = null;
        }
    }
    async readFromDisk() {
        const raw = await readFile(this.path, 'utf8');
        return normalizeState(JSON.parse(raw));
    }
    async writeToDisk(state) {
        await mkdir(path.dirname(this.path), { recursive: true, mode: 0o700 });
        const tempPath = `${this.path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
        const handle = await open(tempPath, 'w', 0o600);
        try {
            await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, 'utf8');
        }
        finally {
            await handle.close();
        }
        await rename(tempPath, this.path);
    }
    enqueue(fn) {
        const run = () => fn();
        const result = this.updateQueue.then(run, run);
        this.updateQueue = result.then(() => undefined, () => undefined);
        return result;
    }
}
function defaultOnError(err) {
    console.error('FileStore: coalesced flush failed', err);
}
// Recursively `Object.freeze` so callers of `read()` can't accidentally mutate
// the store's internal state without going through `update()`. Skips already
// frozen subtrees, so freezing a new state that reuses sub-objects from the
// previous frozen state is cheap.
function deepFreeze(value) {
    if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
        return value;
    }
    Object.freeze(value);
    for (const key of Object.keys(value)) {
        deepFreeze(value[key]);
    }
    return value;
}
//# sourceMappingURL=fileStore.js.map