import fs from "node:fs/promises";
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import path from "node:path";
import crypto from "node:crypto";
import { log } from "./logger.js";
import { encodeCwd } from "./encode-cwd.js";
export const DISPLAY_ITEM_CUSTOM_KIND = "display_item";
/** Custom-entry kind for a Nolan Grout (mentor agent) turn. Nolan's advisory
 *  conversation is NOT part of the LLM message history (EZ Coder never sees it),
 *  but it's persisted alongside the build session so it survives resume. Stored
 *  as a `custom` entry with `parentId: null` so it is NEVER on the message DAG
 *  branch — this keeps it out of `getMessages()` AND avoids racing the build
 *  session's leaf pointer (Nolan runs concurrently). `afterMessageCount` is the
 *  number of non-system messages that existed when the turn was recorded, used
 *  to interleave Nolan turns back into the transcript chronologically. */
export const NOLAN_TURN_CUSTOM_KIND = "nolan_turn";
function isCompletedItemLike(value) {
    return (typeof value === "object" &&
        value !== null &&
        typeof value.kind === "string" &&
        typeof value.id === "string");
}
// ── Session Manager ────────────────────────────────────────
export class SessionManager {
    sessionsDir;
    warnedPersistCodes = new Set();
    /** Called once per error code when session persistence fails (e.g. ENOSPC). */
    onPersistError;
    constructor(sessionsDir) {
        this.sessionsDir = sessionsDir;
    }
    /**
     * Session persistence must never crash a live session. Disk-full (ENOSPC),
     * permission, or quota errors during transcript writes are reported once
     * per error code and otherwise swallowed — the in-memory session keeps going.
     */
    handlePersistError(error, op) {
        const err = error;
        const code = err?.code ?? "UNKNOWN";
        if (this.warnedPersistCodes.has(code))
            return;
        this.warnedPersistCodes.add(code);
        log("WARN", "session", `Session persistence failed (${op}); continuing without saving`, {
            code,
            message: err?.message ?? String(error),
        });
        this.onPersistError?.(err);
    }
    dirForCwd(cwd) {
        return path.join(this.sessionsDir, encodeCwd(cwd));
    }
    async create(cwd, provider, model) {
        const id = crypto.randomUUID();
        const timestamp = new Date().toISOString();
        const dir = this.dirForCwd(cwd);
        await fs.mkdir(dir, { recursive: true });
        const fileName = `${timestamp.replace(/[:.]/g, "-")}_${id.slice(0, 8)}.jsonl`;
        const filePath = path.join(dir, fileName);
        const header = {
            type: "session",
            version: 2,
            id,
            timestamp,
            cwd,
            provider,
            model,
            leafId: null,
        };
        await fs.appendFile(filePath, JSON.stringify(header) + "\n", "utf-8");
        return { id, path: filePath, header };
    }
    async load(sessionPath) {
        // Stream the JSONL file line-by-line instead of loading the entire
        // file into memory. For large sessions (100MB+) this avoids holding
        // the raw string, the split array, and the parsed objects all at once.
        let header = null;
        const entries = [];
        const rl = createInterface({
            input: createReadStream(sessionPath, { encoding: "utf-8" }),
            crlfDelay: Infinity,
        });
        for await (const line of rl) {
            if (!line)
                continue;
            try {
                const parsed = JSON.parse(line);
                if (parsed.type === "session") {
                    if (parsed.version === 2) {
                        header = parsed;
                    }
                    else {
                        // Upgrade v1 to v2
                        const v1 = parsed;
                        header = {
                            type: "session",
                            version: 2,
                            id: v1.id,
                            timestamp: v1.timestamp,
                            cwd: v1.cwd,
                            provider: v1.provider,
                            model: v1.model,
                            leafId: null,
                        };
                    }
                }
                else if (parsed.type === "message") {
                    // v1 compat: entries without id/parentId
                    const entry = parsed;
                    if (!entry.id) {
                        entry.id = crypto.randomUUID();
                        entry.parentId = null;
                    }
                    entries.push(entry);
                }
                else {
                    entries.push(parsed);
                }
            }
            catch {
                // Skip malformed JSON lines — a corrupt line shouldn't crash the session
            }
        }
        if (!header) {
            throw new Error(`Invalid session file: no header found in ${sessionPath}`);
        }
        return { header, entries };
    }
    async list(cwd) {
        const dir = this.dirForCwd(cwd);
        let files;
        try {
            files = await fs.readdir(dir);
        }
        catch {
            return [];
        }
        const sessions = [];
        for (const file of files) {
            if (!file.endsWith(".jsonl"))
                continue;
            const filePath = path.join(dir, file);
            try {
                // Stream line-by-line to avoid loading entire file for listing
                const rl = createInterface({
                    input: createReadStream(filePath, { encoding: "utf-8" }),
                    crlfDelay: Infinity,
                });
                let first = null;
                let messageCount = 0;
                let lastActivity = null;
                for await (const line of rl) {
                    if (!line)
                        continue;
                    try {
                        const parsed = JSON.parse(line);
                        if (!first) {
                            if (parsed.type !== "session")
                                break;
                            first = parsed;
                        }
                        else if (parsed.type === "message") {
                            messageCount++;
                            if (parsed.timestamp)
                                lastActivity = parsed.timestamp;
                        }
                    }
                    catch {
                        // Skip malformed lines
                    }
                }
                if (!first || first.type !== "session")
                    continue;
                sessions.push({
                    id: first.id,
                    path: filePath,
                    timestamp: first.timestamp,
                    lastActivity: lastActivity ?? first.timestamp,
                    cwd: first.cwd,
                    messageCount,
                });
            }
            catch {
                // Skip corrupt files
            }
        }
        // Sort by last activity descending (the session most recently spoken in first)
        sessions.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
        return sessions;
    }
    async getMostRecent(cwd) {
        const sessions = await this.list(cwd);
        const withMessages = sessions.find((s) => s.messageCount > 0);
        return withMessages?.path ?? null;
    }
    async findById(cwd, sessionId) {
        const sessions = await this.list(cwd);
        return sessions.find((session) => session.id === sessionId)?.path ?? null;
    }
    /**
     * Delete session files older than `maxAgeDays` across ALL project dirs.
     * Age is judged by file mtime, so a session that's still being appended to
     * is never considered old. Best-effort: per-file errors are skipped so a
     * locked or vanished file can't break startup. Empty project dirs left
     * behind are removed. Returns what was freed for logging.
     */
    async pruneOldSessions(options) {
        const result = { deletedFiles: 0, freedBytes: 0 };
        if (options.maxAgeDays <= 0)
            return result;
        const cutoffMs = Date.now() - options.maxAgeDays * 86_400_000;
        const keep = new Set((options.keepPaths ?? []).map((p) => path.resolve(p)));
        let cwdDirs;
        try {
            cwdDirs = await fs.readdir(this.sessionsDir);
        }
        catch {
            return result;
        }
        for (const dirName of cwdDirs) {
            const dir = path.join(this.sessionsDir, dirName);
            let files;
            try {
                const stat = await fs.stat(dir);
                if (!stat.isDirectory())
                    continue;
                files = await fs.readdir(dir);
            }
            catch {
                continue;
            }
            let remaining = files.length;
            for (const file of files) {
                if (!file.endsWith(".jsonl"))
                    continue;
                const filePath = path.join(dir, file);
                if (keep.has(path.resolve(filePath)))
                    continue;
                try {
                    const stat = await fs.stat(filePath);
                    if (stat.mtimeMs >= cutoffMs)
                        continue;
                    await fs.unlink(filePath);
                    result.deletedFiles += 1;
                    result.freedBytes += stat.size;
                    remaining -= 1;
                }
                catch {
                    // Skip files we can't stat/delete — pruning is best-effort
                }
            }
            if (remaining === 0) {
                await fs.rmdir(dir).catch(() => { });
            }
        }
        return result;
    }
    async appendEntry(sessionPath, entry) {
        try {
            await fs.appendFile(sessionPath, JSON.stringify(entry) + "\n", "utf-8");
        }
        catch (error) {
            this.handlePersistError(error, "appendEntry");
        }
    }
    async updateLeaf(sessionPath, leafId) {
        try {
            await this.updateLeafUnsafe(sessionPath, leafId);
        }
        catch (error) {
            this.handlePersistError(error, "updateLeaf");
        }
    }
    async updateLeafUnsafe(sessionPath, leafId) {
        // Read only the first line (the header) instead of loading the entire file.
        // For large session files (100MB+), this avoids a full file read+write.
        const fd = await fs.open(sessionPath, "r+");
        try {
            // Read enough bytes to cover the header line (typically <500 bytes)
            const buf = Buffer.alloc(4096);
            const { bytesRead } = await fd.read(buf, 0, 4096, 0);
            const chunk = buf.toString("utf-8", 0, bytesRead);
            const newlineIdx = chunk.indexOf("\n");
            if (newlineIdx === -1)
                return;
            const headerLine = chunk.slice(0, newlineIdx);
            const header = JSON.parse(headerLine);
            if (header.type !== "session")
                return;
            header.leafId = leafId;
            const newHeaderLine = JSON.stringify(header);
            if (newHeaderLine.length === headerLine.length) {
                // Same length — overwrite in place (fast path)
                await fd.write(newHeaderLine, 0, "utf-8");
            }
            else {
                // Different length — must rewrite the file (rare: only on first leafId set)
                await fd.close();
                const content = await fs.readFile(sessionPath, "utf-8");
                const firstNewline = content.indexOf("\n");
                await fs.writeFile(sessionPath, newHeaderLine + content.slice(firstNewline), "utf-8");
                return;
            }
        }
        finally {
            // fd.close() may have already been called in the else branch above,
            // but calling it again on a closed handle is a no-op in Node >= 20.
            await fd.close().catch(() => { });
        }
    }
    /**
     * Get messages for the current branch. If leafId is set, walks the
     * DAG from leaf to root. Otherwise returns all entries linearly.
     */
    getMessages(entries, leafId) {
        const branch = leafId ? this.getBranch(entries, leafId) : entries;
        const messages = branch
            .filter((e) => e.type === "message")
            .map((e) => e.message)
            .filter((m) => m.role !== "system");
        // Repair orphaned tool_use blocks that lack matching tool_result messages.
        // This can happen when a session is interrupted mid-tool-execution.
        return SessionManager.repairToolPairs(messages);
    }
    getDisplayItems(entries, _leafId) {
        return entries.flatMap((entry) => {
            if (entry.type !== "custom" || entry.kind !== DISPLAY_ITEM_CUSTOM_KIND)
                return [];
            const payload = entry.data;
            const item = payload?.version === 1 ? payload.item : undefined;
            return isCompletedItemLike(item) ? [item] : [];
        });
    }
    /** Read all persisted Nolan turns in file order. Returns them regardless of
     *  branch (Nolan turns are not chained into the DAG), validated + normalized. */
    getNolanTurns(entries) {
        return entries.flatMap((entry) => {
            if (entry.type !== "custom" || entry.kind !== NOLAN_TURN_CUSTOM_KIND)
                return [];
            const p = entry.data;
            if (p?.version === 1 && typeof p.question === "string" && typeof p.reply === "string") {
                return [
                    {
                        version: 1,
                        question: p.question,
                        reply: p.reply,
                        afterMessageCount: typeof p.afterMessageCount === "number" ? p.afterMessageCount : 0,
                    },
                ];
            }
            return [];
        });
    }
    /**
     * Ensure every assistant message with tool_use blocks is followed by a tool
     * message containing matching tool_result entries. Inserts synthetic
     * tool_result messages where needed to prevent Anthropic API 400 errors.
     */
    static repairToolPairs(messages) {
        const repaired = [];
        for (let i = 0; i < messages.length; i++) {
            const msg = messages[i];
            repaired.push(msg);
            if (msg.role !== "assistant")
                continue;
            const content = Array.isArray(msg.content) ? msg.content : [];
            const toolUseIds = content
                .filter((p) => p.type === "tool_call")
                .map((p) => p.id);
            if (toolUseIds.length === 0)
                continue;
            // Check if the next message is a tool message with matching results
            const next = messages[i + 1];
            if (next?.role === "tool" && Array.isArray(next.content)) {
                const existingIds = new Set(next.content.map((r) => r.toolCallId));
                const missing = toolUseIds.filter((id) => !existingIds.has(id));
                if (missing.length > 0) {
                    // Patch the existing tool message with missing results
                    for (const id of missing) {
                        next.content.push({
                            type: "tool_result",
                            toolCallId: id,
                            content: "Tool execution was interrupted.",
                            isError: true,
                        });
                    }
                }
            }
            else {
                // No tool message follows — insert a synthetic one
                repaired.push({
                    role: "tool",
                    content: toolUseIds.map((id) => ({
                        type: "tool_result",
                        toolCallId: id,
                        content: "Tool execution was interrupted.",
                        isError: true,
                    })),
                });
            }
        }
        return repaired;
    }
    /**
     * Build a lookup Map from entry id → entry. Reusable across multiple
     * getBranch / listBranches calls on the same entry set.
     */
    buildIndex(entries) {
        return new Map(entries.map((e) => [e.id, e]));
    }
    /**
     * Walk the DAG from a leaf entry back to the root, returning entries
     * in chronological order (root → leaf). This is the "branch" — the
     * path through the conversation tree that leads to the given leaf.
     *
     * Accepts an optional pre-built index to avoid redundant Map allocations
     * when called in a loop.
     */
    getBranch(entries, leafId, byId) {
        if (!leafId)
            return entries;
        const index = byId ?? this.buildIndex(entries);
        const branch = [];
        let current = leafId;
        while (current) {
            const entry = index.get(current);
            if (!entry)
                break;
            branch.push(entry);
            current = entry.parentId;
        }
        return branch.reverse();
    }
    /**
     * List all branches (leaf nodes) in a session's entry DAG.
     * A leaf is any entry whose id is not referenced as a parentId by any other entry.
     */
    listBranches(entries) {
        if (entries.length === 0)
            return [];
        // Build shared index once — reused by every getBranch call below
        const byId = this.buildIndex(entries);
        // Find all ids that are referenced as parentId
        const parentIds = new Set(entries.map((e) => e.parentId).filter(Boolean));
        // Leaves = entries whose id is NOT in parentIds
        const leaves = entries.filter((e) => !parentIds.has(e.id));
        // Build childCount once — was previously rebuilt per-leaf (O(n²))
        const childCount = new Map();
        for (const e of entries) {
            childCount.set(e.parentId, (childCount.get(e.parentId) ?? 0) + 1);
        }
        return leaves.map((leaf) => {
            const branch = this.getBranch(entries, leaf.id, byId);
            let branchPointId = branch[0]?.id ?? leaf.id;
            for (const e of branch) {
                if ((childCount.get(e.parentId) ?? 0) > 1) {
                    branchPointId = e.id;
                    break;
                }
            }
            return {
                branchPointId,
                leafId: leaf.id,
                entryCount: branch.length,
                timestamp: branch[0]?.timestamp ?? leaf.timestamp,
            };
        });
    }
}
//# sourceMappingURL=session-manager.js.map