import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { getAppConfig } from '../config/index.js';
import { getAppDataPath } from '../config/paths.js';
/** UUID v4 pattern for session ID validation (prevents path traversal) */
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
function isValidSessionId(id) {
    return SESSION_ID_PATTERN.test(id);
}
function isRecord(obj) {
    return typeof obj === 'object' && obj !== null && !Array.isArray(obj);
}
function isValidSessionMetadata(obj) {
    if (!isRecord(obj))
        return false;
    return (typeof obj.id === 'string' &&
        typeof obj.title === 'string' &&
        typeof obj.createdAt === 'string' &&
        typeof obj.lastAccessedAt === 'string' &&
        typeof obj.messageCount === 'number' &&
        typeof obj.provider === 'string' &&
        typeof obj.model === 'string' &&
        typeof obj.workingDirectory === 'string');
}
function isValidSession(obj) {
    if (!isRecord(obj))
        return false;
    return isValidSessionMetadata(obj) && Array.isArray(obj.messages);
}
/** Write data to a temp file then atomically rename into place. */
async function atomicWriteFile(filePath, data, mode) {
    const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`;
    try {
        await fs.writeFile(tmpPath, data, { mode });
        await fs.rename(tmpPath, filePath);
    }
    catch (error) {
        // Clean up temp file on failure
        try {
            await fs.unlink(tmpPath);
        }
        catch (_cleanupError) {
            // Ignore cleanup errors
        }
        throw error;
    }
}
function isEnoent(error) {
    return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class SessionManager {
    sessionsDir;
    sessionsIndexPath;
    initialized = false;
    /** Serializes read-modify-write of sessions.json to prevent lost updates from concurrent autosave/resume. */
    indexWriteLock = Promise.resolve();
    /** Optional explicit directory override (used by tests). */
    overrideDir;
    constructor(sessionsDir) {
        this.overrideDir = sessionsDir;
    }
    resolveSessionsDir() {
        if (this.overrideDir) {
            this.sessionsDir = this.overrideDir;
            this.sessionsIndexPath = path.join(this.sessionsDir, 'sessions.json');
            return;
        }
        const config = getAppConfig();
        const sessionConfig = config.sessions;
        const configuredDir = sessionConfig?.directory;
        if (configuredDir) {
            // User explicitly configured a directory — expand tilde
            let sessionDirPath = configuredDir;
            if (sessionDirPath === '~') {
                sessionDirPath = path.resolve(process.env.HOME || process.env.USERPROFILE || '.');
            }
            else if (sessionDirPath.startsWith('~/')) {
                sessionDirPath = path.join(process.env.HOME || process.env.USERPROFILE || '.', sessionDirPath.slice(2));
            }
            this.sessionsDir = sessionDirPath;
        }
        else {
            // Default: use platform-aware app data path
            this.sessionsDir = path.join(getAppDataPath(), 'sessions');
        }
        this.sessionsIndexPath = path.join(this.sessionsDir, 'sessions.json');
    }
    async initialize() {
        if (this.initialized)
            return;
        this.resolveSessionsDir();
        try {
            await fs.mkdir(this.sessionsDir, { recursive: true, mode: 0o700 });
            await fs.chmod(this.sessionsDir, 0o700);
            try {
                await fs.access(this.sessionsIndexPath);
            }
            catch (_error) {
                await atomicWriteFile(this.sessionsIndexPath, JSON.stringify([]), 0o600);
            }
            this.initialized = true;
            // Perform cleanup of old sessions if configured
            await this.cleanupOldSessions();
        }
        catch (error) {
            console.error('Failed to initialize session directory:', error);
            throw error;
        }
    }
    async createSession(sessionData) {
        const sessionId = crypto.randomUUID();
        const timestamp = new Date().toISOString();
        const session = {
            id: sessionId,
            title: sessionData.title,
            createdAt: timestamp,
            lastAccessedAt: timestamp,
            messageCount: sessionData.messageCount,
            provider: sessionData.provider,
            model: sessionData.model,
            workingDirectory: sessionData.workingDirectory,
            messages: sessionData.messages,
        };
        await this.saveSession(session);
        await this.enforceSessionLimits();
        return session;
    }
    async saveSession(session) {
        if (!isValidSessionId(session.id)) {
            throw new Error(`Invalid session ID: ${session.id}`);
        }
        // File write and index update happen together under the lock
        // to prevent orphaned files if the process dies between them.
        await this.withIndexLock(async () => {
            const sessionFilePath = path.join(this.sessionsDir, `${session.id}.json`);
            // Write session file atomically
            await atomicWriteFile(sessionFilePath, JSON.stringify(session, null, 2), 0o600);
            // Update index
            const sessions = await this.readIndex();
            const existingSessionIndex = sessions.findIndex(s => s.id === session.id);
            const sessionMetadata = {
                id: session.id,
                title: session.title,
                createdAt: session.createdAt,
                lastAccessedAt: session.lastAccessedAt,
                messageCount: session.messageCount,
                provider: session.provider,
                model: session.model,
                workingDirectory: session.workingDirectory,
            };
            if (existingSessionIndex >= 0) {
                sessions[existingSessionIndex] = sessionMetadata;
            }
            else {
                sessions.push(sessionMetadata);
            }
            await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(sessions, null, 2), 0o600);
        });
    }
    /** Read the index file (internal helper — not locked). */
    async readIndex() {
        try {
            const data = await fs.readFile(this.sessionsIndexPath, 'utf-8');
            const parsed = JSON.parse(data);
            if (!Array.isArray(parsed))
                return this.rebuildIndex();
            const valid = parsed.filter(isValidSessionMetadata);
            if (valid.length === 0 && parsed.length > 0) {
                // Index had entries but none were valid — try recovery
                return this.rebuildIndex();
            }
            return valid;
        }
        catch (_error) {
            return this.rebuildIndex();
        }
    }
    /**
     * Rebuild the index by scanning session files on disk.
     * Called when the index is missing, corrupt, or empty despite files existing.
     */
    async rebuildIndex() {
        try {
            const entries = await fs.readdir(this.sessionsDir);
            const sessionFiles = entries.filter(e => e.endsWith('.json') && e !== 'sessions.json');
            if (sessionFiles.length === 0)
                return [];
            const metadata = [];
            for (const file of sessionFiles) {
                try {
                    const filePath = path.join(this.sessionsDir, file);
                    const data = await fs.readFile(filePath, 'utf-8');
                    const parsed = JSON.parse(data);
                    if (isValidSession(parsed)) {
                        metadata.push({
                            id: parsed.id,
                            title: parsed.title,
                            createdAt: parsed.createdAt,
                            lastAccessedAt: parsed.lastAccessedAt,
                            messageCount: parsed.messageCount,
                            provider: parsed.provider,
                            model: parsed.model,
                            workingDirectory: parsed.workingDirectory,
                        });
                    }
                }
                catch (_fileError) {
                    // Skip unreadable files
                }
            }
            // Persist rebuilt index
            if (metadata.length > 0) {
                await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(metadata, null, 2), 0o600);
            }
            return metadata;
        }
        catch (_error) {
            return [];
        }
    }
    async listSessions(options) {
        const sessions = await this.readIndex();
        if (options?.workingDirectory) {
            const normalized = path.normalize(options.workingDirectory);
            return sessions.filter(s => path.normalize(s.workingDirectory) === normalized);
        }
        return sessions;
    }
    /** Read a session from disk without updating lastAccessedAt (no write). */
    async readSession(sessionId) {
        if (!isValidSessionId(sessionId))
            return null;
        try {
            const sessionFilePath = path.join(this.sessionsDir, `${sessionId}.json`);
            const data = await fs.readFile(sessionFilePath, 'utf-8');
            const parsed = JSON.parse(data);
            if (!isValidSession(parsed))
                return null;
            return parsed;
        }
        catch (_error) {
            return null;
        }
    }
    async loadSession(sessionId) {
        const session = await this.readSession(sessionId);
        if (!session)
            return null;
        // Update last accessed time
        const updatedSession = {
            ...session,
            lastAccessedAt: new Date().toISOString(),
        };
        await this.saveSession(updatedSession);
        return updatedSession;
    }
    async deleteSession(sessionId) {
        if (!isValidSessionId(sessionId)) {
            throw new Error(`Invalid session ID: ${sessionId}`);
        }
        const sessionFilePath = path.join(this.sessionsDir, `${sessionId}.json`);
        // Delete file — only ignore ENOENT
        try {
            await fs.unlink(sessionFilePath);
        }
        catch (error) {
            if (!isEnoent(error)) {
                throw error;
            }
        }
        // Update index — let errors propagate
        await this.withIndexLock(async () => {
            const sessions = await this.readIndex();
            const filteredSessions = sessions.filter(s => s.id !== sessionId);
            await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(filteredSessions, null, 2), 0o600);
        });
    }
    getSessionDirectory() {
        return this.sessionsDir;
    }
    /** Run a read-modify-write on the index one at a time to avoid lost updates. */
    async withIndexLock(fn) {
        const prev = this.indexWriteLock;
        let release;
        this.indexWriteLock = new Promise(r => {
            release = r;
        });
        await prev;
        try {
            return await fn();
        }
        finally {
            release();
        }
    }
    async enforceSessionLimits() {
        const config = getAppConfig();
        const sessionConfig = config.sessions;
        const maxSessions = sessionConfig?.maxSessions || 100;
        await this.withIndexLock(async () => {
            const sessions = await this.readIndex();
            if (sessions.length <= maxSessions)
                return;
            // Sort by lastAccessedAt ascending (oldest first)
            const sortedSessions = sessions.sort((a, b) => new Date(a.lastAccessedAt).getTime() -
                new Date(b.lastAccessedAt).getTime());
            const sessionsToDelete = sortedSessions.slice(0, sessions.length - maxSessions);
            const idsToDelete = new Set(sessionsToDelete.map(s => s.id));
            // Rewrite index first so sessions are deregistered even if
            // file deletion partially fails.
            const remaining = sortedSessions.filter(s => !idsToDelete.has(s.id));
            await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(remaining, null, 2), 0o600);
            // Then delete files — only ignore ENOENT
            for (const session of sessionsToDelete) {
                const filePath = path.join(this.sessionsDir, `${session.id}.json`);
                try {
                    await fs.unlink(filePath);
                }
                catch (error) {
                    if (!isEnoent(error)) {
                        throw error;
                    }
                }
            }
        });
    }
    async cleanupOldSessions() {
        const config = getAppConfig();
        const sessionConfig = config.sessions;
        const retentionDays = sessionConfig?.retentionDays || 30;
        const cutoffDate = new Date();
        cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
        await this.withIndexLock(async () => {
            const sessions = await this.readIndex();
            const oldSessions = sessions.filter(session => new Date(session.lastAccessedAt) < cutoffDate);
            if (oldSessions.length === 0)
                return;
            const idsToDelete = new Set(oldSessions.map(s => s.id));
            // Rewrite index first so sessions are deregistered even if
            // file deletion partially fails.
            const remaining = sessions.filter(s => !idsToDelete.has(s.id));
            await atomicWriteFile(this.sessionsIndexPath, JSON.stringify(remaining, null, 2), 0o600);
            // Then delete files — only ignore ENOENT
            for (const session of oldSessions) {
                const filePath = path.join(this.sessionsDir, `${session.id}.json`);
                try {
                    await fs.unlink(filePath);
                }
                catch (error) {
                    if (!isEnoent(error)) {
                        throw error;
                    }
                }
            }
        });
    }
}
// Export singleton instance — config is deferred to initialize()
export const sessionManager = new SessionManager();
//# sourceMappingURL=session-manager.js.map