/**
 * Session Manager — groups web UI entity edits into edit sessions for clean git history.
 *
 * Each session is scoped to a (user, repo) pair. Edits extend the session's
 * idle timer. When a session goes idle for SESSION_IDLE_MS, a single commit
 * is made covering all entities touched during that session, and a
 * `session_commit` WebSocket event is broadcast.
 *
 * Persistence: the dirty set is mirrored to
 * `<workspace>/.studiograph/pending-commits.json` on every markDirty/flush.
 * On boot, recoverOnBoot() reads that file AND scans every repo for
 * uncommitted working-tree changes — anything found is committed under a
 * "system recovery" attribution. This guarantees no edit ever lives only in
 * memory: a crash, OOM, or hard restart leaves the working tree dirty but
 * recoverable on the next boot.
 */
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
import { join } from 'path';
import { recordCommitAudit } from './commit-audit.js';
const SESSION_IDLE_MS = 5 * 60_000; // 5 minutes idle → commit
const CHECK_INTERVAL_MS = 30_000; // check for stale sessions every 30s
const RECOVERY_USER = {
    id: 'studiograph-recovery',
    name: 'Studiograph (recovery)',
    email: 'recovery@studiograph.local',
};
export class SessionManager {
    sessions = new Map(); // key: `${userId}:${repo}`
    timer = null;
    workspaceManager;
    wsHub;
    pendingPath;
    constructor(workspaceManager, wsHub) {
        this.workspaceManager = workspaceManager;
        this.wsHub = wsHub;
        this.pendingPath = join(workspaceManager.getWorkspacePath(), '.studiograph', 'pending-commits.json');
        this.timer = setInterval(() => this.flushStale(), CHECK_INTERVAL_MS);
    }
    /**
     * On boot: load any persisted sessions from the previous process, then
     * walk every repo and commit anything in the working tree that wasn't
     * committed (a crash, OOM, or hard restart left it dirty). Always safe
     * to call — no-ops cleanly when there's nothing to recover.
     */
    async recoverOnBoot() {
        let totalCommits = 0;
        // 1. Replay persisted sessions (preserves original user attribution).
        const persisted = this.loadPersisted();
        let persistedRepos = 0;
        if (persisted && persisted.sessions.length > 0) {
            for (const ps of persisted.sessions) {
                const session = {
                    user: ps.user,
                    repo: ps.repo,
                    lastActivityAt: ps.lastActivityAt,
                    entities: new Set(ps.entities),
                    filePaths: new Set(ps.filePaths),
                };
                const committed = await this.commitSession(session, 'recovery-persisted');
                if (committed)
                    totalCommits += 1;
                persistedRepos += 1;
            }
            // Clear persisted state — anything still dirty after this falls
            // through to the working-tree sweep below.
            this.persist();
        }
        // 2. Working-tree sweep — catches anything that bypassed the session
        //    map entirely (silent commit failures, direct fs writes, etc.).
        let sweptRepos = 0;
        for (const repoName of this.workspaceManager.getRepoNames()) {
            try {
                const graph = this.workspaceManager.getGraph(repoName);
                const gitService = graph.getGitService();
                if (!gitService)
                    continue;
                if (!gitService.hasUncommittedChanges())
                    continue;
                const status = gitService.getStatus();
                const fileCount = status.modified.length + status.added.length + status.deleted.length + status.untracked.length;
                const message = `recovery: commit ${fileCount} uncommitted file(s) from prior session`;
                gitService.commitAll(message, RECOVERY_USER);
                recordCommitAudit(this.workspaceManager.getWorkspacePath(), {
                    repo: repoName,
                    source: 'recovery',
                    user: RECOVERY_USER,
                    message,
                    fileCount,
                });
                sweptRepos += 1;
                totalCommits += 1;
                console.log(`[session-manager] recovery: committed ${fileCount} file(s) in ${repoName}`);
            }
            catch (err) {
                console.warn(`[session-manager] recovery sweep failed for ${repoName}:`, err.message);
            }
        }
        if (persistedRepos > 0 || sweptRepos > 0) {
            console.log(`[session-manager] recoverOnBoot: ${persistedRepos} persisted, ${sweptRepos} swept, ${totalCommits} commit(s)`);
        }
        return { persistedRepos, sweptRepos, totalCommits };
    }
    /**
     * Record a disk write within a user's session for the given repo.
     * Resets the idle timer and accumulates entity + file path info.
     */
    markDirty(repoName, user, entityType, entityId, filePath) {
        const key = `${user.id}:${repoName}`;
        const existing = this.sessions.get(key);
        if (existing) {
            existing.lastActivityAt = Date.now();
            existing.entities.add(`${entityType}/${entityId}`);
            if (filePath)
                existing.filePaths.add(filePath);
        }
        else {
            const session = {
                user,
                repo: repoName,
                lastActivityAt: Date.now(),
                entities: new Set([`${entityType}/${entityId}`]),
                filePaths: new Set(),
            };
            if (filePath)
                session.filePaths.add(filePath);
            this.sessions.set(key, session);
        }
        this.persist();
    }
    /**
     * Flush all sessions for a repo immediately.
     * Used by the /api/repos/:repo/commit route (CLI compatibility).
     * Returns true if anything was committed.
     */
    async flush(repoName) {
        const toFlush = [];
        for (const [key, session] of this.sessions) {
            if (session.repo === repoName)
                toFlush.push([key, session]);
        }
        if (toFlush.length === 0)
            return false;
        for (const [key] of toFlush)
            this.sessions.delete(key);
        this.persist();
        const results = await Promise.all(toFlush.map(([, session]) => this.commitSession(session, 'session-flush')));
        return results.some(Boolean);
    }
    /**
     * Flush all open sessions (called on server shutdown via shutdown()).
     */
    async flushAll() {
        const entries = Array.from(this.sessions.entries());
        if (entries.length > 0) {
            console.log(`[session-manager] flushAll: flushing ${entries.length} open session(s) on shutdown`);
        }
        this.sessions.clear();
        this.persist();
        await Promise.all(entries.map(([, session]) => this.commitSession(session, 'session-flush')));
        if (entries.length > 0) {
            console.log(`[session-manager] flushAll: done`);
        }
    }
    /**
     * Graceful shutdown — flushes pending sessions then tears down the timer.
     * Safe to call multiple times. Always prefer this over destroy().
     */
    async shutdown() {
        await this.flushAll();
        this.destroy();
    }
    async flushStale() {
        const now = Date.now();
        const toFlush = [];
        for (const [key, session] of this.sessions) {
            if (now - session.lastActivityAt >= SESSION_IDLE_MS) {
                toFlush.push([key, session]);
            }
        }
        if (toFlush.length === 0)
            return;
        for (const [key] of toFlush)
            this.sessions.delete(key);
        this.persist();
        await Promise.all(toFlush.map(([, session]) => this.commitSession(session, 'session-flush')));
    }
    async commitSession(session, source) {
        try {
            const graph = this.workspaceManager.getGraph(session.repo);
            const gitService = graph.getGitService();
            if (!gitService)
                return false;
            if (!gitService.hasUncommittedChanges())
                return false;
            // Build a friendly "Edited …" message, resolving each entity's display
            // title from the index at flush time (see Graph.buildEditedBatchMessage).
            // Keys are "type/id"; the id is a slug and never contains a slash.
            const pairs = Array.from(session.entities).map((k) => {
                const slash = k.indexOf('/');
                return slash >= 0 ? { type: k.slice(0, slash), id: k.slice(slash + 1) } : { type: k, id: k };
            });
            const base = pairs.length > 0 ? graph.buildEditedBatchMessage(pairs) : 'Edited entries';
            const message = source === 'recovery-persisted' ? `recovery: ${base}` : base;
            const fileCount = session.filePaths.size;
            if (session.filePaths.size > 0) {
                gitService.commitFiles(Array.from(session.filePaths), message, session.user);
            }
            else {
                gitService.commitAll(message, session.user);
            }
            recordCommitAudit(this.workspaceManager.getWorkspacePath(), {
                repo: session.repo,
                source,
                user: session.user,
                message,
                fileCount,
            });
            this.wsHub?.broadcast({
                type: 'session_commit',
                repo: session.repo,
                entities: Array.from(session.entities),
                actor: session.user.name,
                timestamp: new Date().toISOString(),
            });
            return true;
        }
        catch (err) {
            console.warn(`[session-manager] Failed to commit ${session.repo}:`, err.message);
            return false;
        }
    }
    /**
     * Snapshot of current dirty state, exposed for the health endpoint.
     */
    getDirtySessions() {
        return Array.from(this.sessions.values()).map(s => ({
            repo: s.repo,
            user: s.user.name,
            entities: Array.from(s.entities),
            lastActivityAt: s.lastActivityAt,
        }));
    }
    /**
     * Atomically write the current dirty set to disk. Best-effort: a
     * persistence failure logs and returns; it never throws into the request
     * path. The recovery sweep on next boot covers anything that didn't
     * persist.
     */
    persist() {
        const state = {
            version: 1,
            savedAt: new Date().toISOString(),
            sessions: Array.from(this.sessions.values()).map(s => ({
                user: s.user,
                repo: s.repo,
                lastActivityAt: s.lastActivityAt,
                entities: Array.from(s.entities),
                filePaths: Array.from(s.filePaths),
            })),
        };
        try {
            mkdirSync(join(this.workspaceManager.getWorkspacePath(), '.studiograph'), { recursive: true });
            const tmp = `${this.pendingPath}.tmp`;
            writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8');
            renameSync(tmp, this.pendingPath);
        }
        catch (err) {
            console.warn(`[session-manager] failed to persist pending commits:`, err.message);
        }
    }
    loadPersisted() {
        try {
            if (!existsSync(this.pendingPath))
                return null;
            const raw = readFileSync(this.pendingPath, 'utf-8');
            const parsed = JSON.parse(raw);
            if (parsed.version !== 1 || !Array.isArray(parsed.sessions))
                return null;
            return parsed;
        }
        catch (err) {
            console.warn(`[session-manager] failed to load pending commits:`, err.message);
            return null;
        }
    }
    /**
     * Tear down the timer + clear in-memory state. Does NOT commit pending
     * sessions — call shutdown() for graceful exit. Made private to prevent
     * accidental data loss; only flushAll() and shutdown() should reach it.
     */
    destroy() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
        this.sessions.clear();
    }
}
//# sourceMappingURL=session-manager.js.map