/**
 * Session persistence for Franklin.
 * Saves conversation history as JSONL for resume capability.
 */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { BLOCKRUN_DIR } from '../config.js';
const MAX_SESSIONS = 20; // Keep last 20 sessions
let resolvedSessionsDir = null;
// When in-process tests run interactiveSession() with model="local/test*",
// session writes were creating real .jsonl + .meta.json files in the
// user's ~/.blockrun/sessions/ — verified 19 of 33 metas (57.6%) on a
// real machine. Toggled at session start by the agent loop based on the
// model name; defaults to enabled so production never accidentally goes
// silent. No-op writes when disabled — reads still work so resume tests
// can pre-seed state with their own writes if they want to.
let persistenceDisabled = false;
export function setSessionPersistenceDisabled(disabled) {
    persistenceDisabled = disabled;
}
export function isSessionPersistenceDisabled() {
    return persistenceDisabled;
}
function getSessionsDir() {
    if (resolvedSessionsDir)
        return resolvedSessionsDir;
    const preferred = path.join(BLOCKRUN_DIR, 'sessions');
    const fallback = path.join(os.tmpdir(), 'franklin', 'sessions');
    for (const dir of [preferred, fallback]) {
        try {
            fs.mkdirSync(dir, { recursive: true });
            resolvedSessionsDir = dir;
            return dir;
        }
        catch {
            // Try the next candidate.
        }
    }
    // If both locations fail, keep the preferred path so the original error
    // surfaces from the caller rather than hiding the failure.
    resolvedSessionsDir = preferred;
    return resolvedSessionsDir;
}
function sessionPath(id) {
    return path.join(getSessionsDir(), `${id}.jsonl`);
}
/** Get the absolute path to a session's JSONL file (for external readers like search). */
export function getSessionFilePath(id) {
    return sessionPath(id);
}
function metaPath(id) {
    return path.join(getSessionsDir(), `${id}.meta.json`);
}
function withWritableSessionDir(action) {
    const preferred = path.join(BLOCKRUN_DIR, 'sessions');
    const fallback = path.join(os.tmpdir(), 'franklin', 'sessions');
    try {
        action();
    }
    catch (err) {
        const code = err.code;
        const shouldFallback = (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') &&
            resolvedSessionsDir === preferred;
        if (!shouldFallback)
            throw err;
        fs.mkdirSync(fallback, { recursive: true });
        resolvedSessionsDir = fallback;
        action();
    }
}
/**
 * Create a new session ID based on timestamp.
 */
export function createSessionId() {
    const now = new Date();
    const ts = now.toISOString().replace(/[:.]/g, '-');
    const suffix = randomUUID().slice(0, 8);
    return `session-${ts}-${suffix}`;
}
/**
 * Save a message to the session transcript (append-only JSONL).
 */
export function appendToSession(sessionId, message) {
    if (persistenceDisabled)
        return;
    const line = JSON.stringify(message) + '\n';
    withWritableSessionDir(() => {
        fs.appendFileSync(sessionPath(sessionId), line);
    });
}
/**
 * Update session metadata.
 */
export function updateSessionMeta(sessionId, meta) {
    if (persistenceDisabled)
        return;
    withWritableSessionDir(() => {
        const existing = loadSessionMeta(sessionId);
        const updated = {
            id: sessionId,
            model: meta.model || existing?.model || 'unknown',
            workDir: meta.workDir || existing?.workDir || '',
            createdAt: existing?.createdAt || Date.now(),
            updatedAt: Date.now(),
            turnCount: meta.turnCount ?? existing?.turnCount ?? 0,
            messageCount: meta.messageCount ?? existing?.messageCount ?? 0,
            inputTokens: meta.inputTokens ?? existing?.inputTokens ?? 0,
            outputTokens: meta.outputTokens ?? existing?.outputTokens ?? 0,
            costUsd: meta.costUsd ?? existing?.costUsd ?? 0,
            savedVsOpusUsd: meta.savedVsOpusUsd ?? existing?.savedVsOpusUsd ?? 0,
            ...(meta.channel !== undefined || existing?.channel !== undefined
                ? { channel: meta.channel ?? existing?.channel }
                : {}),
            // Chain (base / solana) is sticky once set. We never let a later
            // update overwrite an existing value with undefined — that would
            // silently drop the bind-to-original-chain guarantee.
            ...(meta.chain !== undefined || existing?.chain !== undefined
                ? { chain: existing?.chain ?? meta.chain }
                : {}),
            ...(meta.toolCallCounts !== undefined || existing?.toolCallCounts !== undefined
                ? { toolCallCounts: meta.toolCallCounts ?? existing?.toolCallCounts }
                : {}),
        };
        // Atomic write: tmp file + rename. Prevents corruption when parent
        // and sub-agent update the same session meta concurrently.
        // On Windows, renameSync can throw EEXIST/EPERM on older filesystems —
        // fall back to a direct write (non-atomic but still functional) and
        // clean up the orphan tmp file.
        const target = metaPath(sessionId);
        const tmp = target + '.tmp';
        const payload = JSON.stringify(updated, null, 2);
        try {
            fs.writeFileSync(tmp, payload);
            fs.renameSync(tmp, target);
        }
        catch {
            // Best-effort: clean up the orphan tmp, then write target directly.
            try {
                fs.unlinkSync(tmp);
            }
            catch { /* may not exist */ }
            try {
                fs.writeFileSync(target, payload);
            }
            catch { /* give up; stats just get stale */ }
        }
    });
}
/**
 * Load session metadata.
 */
export function loadSessionMeta(sessionId) {
    try {
        return JSON.parse(fs.readFileSync(metaPath(sessionId), 'utf-8'));
    }
    catch {
        return null;
    }
}
/**
 * Load full session history from JSONL.
 */
export function loadSessionHistory(sessionId) {
    try {
        const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
        const lines = content.trim().split('\n').filter(Boolean);
        const results = [];
        for (const line of lines) {
            try {
                results.push(JSON.parse(line));
            }
            catch {
                // Skip corrupted lines — partial writes from crashes
                continue;
            }
        }
        return results;
    }
    catch {
        return [];
    }
}
function readSessionMetas(includeGhosts = false) {
    const sessionsDir = getSessionsDir();
    try {
        const files = fs.readdirSync(sessionsDir)
            .filter(f => f.endsWith('.meta.json'));
        const metas = [];
        for (const file of files) {
            try {
                const meta = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf-8'));
                metas.push(meta);
            }
            catch { /* skip corrupted */ }
        }
        const visible = includeGhosts ? metas : metas.filter(m => m.messageCount > 0);
        return visible.sort((a, b) => b.updatedAt - a.updatedAt);
    }
    catch {
        return [];
    }
}
/**
 * List all saved sessions, newest first.
 */
export function listSessions() {
    return readSessionMetas(false);
}
/**
 * Find the latest saved session tagged with a given channel (e.g.
 * `telegram:12345`). Used by non-CLI drivers to resume across process
 * restarts. Returns undefined when no matching session exists.
 */
export function findLatestSessionByChannel(channel) {
    return listSessions().find(m => m.channel === channel);
}
/**
 * Prune old sessions beyond MAX_SESSIONS.
 */
/**
 * Prune old sessions beyond MAX_SESSIONS.
 * Accepts optional activeSessionId to protect from deletion.
 */
export function pruneOldSessions(activeSessionId) {
    const sessions = readSessionMetas(false);
    const allSessions = readSessionMetas(true);
    if (sessions.length > MAX_SESSIONS) {
        const toDelete = sessions
            .slice(MAX_SESSIONS)
            .filter(s => s.id !== activeSessionId); // Never delete active session
        for (const s of toDelete) {
            try {
                fs.unlinkSync(sessionPath(s.id));
            }
            catch { /* ok */ }
            try {
                fs.unlinkSync(metaPath(s.id));
            }
            catch { /* ok */ }
        }
    }
    // Also clean up ghost sessions (0 messages, older than 5 minutes)
    const fiveMinAgo = Date.now() - 5 * 60 * 1000;
    for (const s of allSessions) {
        if (s.id === activeSessionId)
            continue;
        if (s.messageCount === 0 && s.createdAt < fiveMinAgo) {
            try {
                fs.unlinkSync(sessionPath(s.id));
            }
            catch { /* ok */ }
            try {
                fs.unlinkSync(metaPath(s.id));
            }
            catch { /* ok */ }
        }
    }
    // Sweep orphan jsonl files (left over from a session-id format change in
    // earlier releases — meta deleted, jsonl stranded). The pre-3.x naming
    // didn't include the random suffix, so the meta-driven prune above has
    // no record of them and they accumulate forever. Verified on a real
    // user machine: 21 metas, 121 jsonl, 100 orphans = ~1 MB stranded.
    pruneOrphanJsonlFiles(activeSessionId);
}
function pruneOrphanJsonlFiles(activeSessionId) {
    const dir = getSessionsDir();
    let entries;
    try {
        entries = fs.readdirSync(dir);
    }
    catch {
        return; // Sessions dir doesn't exist yet — nothing to prune.
    }
    const knownIds = new Set();
    for (const f of entries) {
        if (f.endsWith('.meta.json')) {
            knownIds.add(f.slice(0, -'.meta.json'.length));
        }
    }
    for (const f of entries) {
        if (!f.endsWith('.jsonl'))
            continue;
        const id = f.slice(0, -'.jsonl'.length);
        if (id === activeSessionId)
            continue;
        if (knownIds.has(id))
            continue;
        // No meta partner — orphan. Delete the jsonl.
        try {
            fs.unlinkSync(path.join(dir, f));
        }
        catch { /* ok */ }
    }
}
