// Tracks tool-driven file changes and writes per-message/session .patch files
// to $KIMAKI_DATA_DIR/diffs/ (default ~/.kimaki/diffs/).
//
// The bot process reads these patch files in emitFooter() to upload them via
// critique and append [ses diff] / [msg diff] links to the footer message.
//
// Lifecycle:
//   tool.execute.before → snapshot file contents before edit
//   tool.execute.after  → snapshot again, record the delta
//   message.updated (completed) → write {sessionId}-{messageId}.patch
//   session.idle         → write {sessionId}.patch (cumulative, overwrites)
//   session.deleted      → cleanup in-memory state
//
// Pruning: caps the diffs folder at MAX_PATCH_FILES, deleting oldest by mtime.
import { createTwoFilesPatch } from 'diff';
import fs from 'node:fs';
import path from 'node:path';
import { extractPatchFilePaths } from './patch-text-parser.js';
const TOOLS = new Set(['edit', 'write', 'apply_patch', 'multiedit']);
// Skip files larger than 512KB to avoid memory spikes
const MAX_FILE_BYTES = 512 * 1024;
// Max patch files before pruning oldest
const MAX_PATCH_FILES = 200;
// Pending tool calls older than 10 min are garbage collected
const PENDING_TTL_MS = 10 * 60 * 1000;
function extractPaths(tool, args) {
    if (tool === 'edit' || tool === 'write' || tool === 'multiedit') {
        return typeof args.filePath === 'string' ? [args.filePath] : [];
    }
    if (tool === 'apply_patch') {
        const text = (args.patchText ?? args.patch ?? '');
        return extractPatchFilePaths(text);
    }
    return [];
}
function buildPatch(files) {
    const parts = [];
    for (const [file, { first, last }] of files) {
        if (first === last) {
            continue;
        }
        parts.push(createTwoFilesPatch(file, file, first, last));
    }
    return parts.join('\n');
}
async function readSafe(file) {
    const stat = await fs.promises.stat(file).catch(() => {
        return undefined;
    });
    if (!stat) {
        return '';
    }
    if (!stat.isFile() || stat.size > MAX_FILE_BYTES) {
        return undefined;
    }
    return fs.promises.readFile(file, 'utf8').catch(() => {
        return '';
    });
}
async function prunePatchDir(dir) {
    const entries = await fs.promises.readdir(dir).catch(() => {
        return [];
    });
    if (entries.length <= MAX_PATCH_FILES) {
        return;
    }
    const files = (await Promise.all(entries.map(async (name) => {
        const full = path.join(dir, name);
        const stat = await fs.promises.stat(full).catch(() => {
            return undefined;
        });
        return stat?.isFile() ? { full, mtime: stat.mtimeMs } : undefined;
    }))).filter((f) => {
        return f !== undefined;
    });
    files.sort((a, b) => {
        return b.mtime - a.mtime;
    });
    await Promise.all(files.slice(MAX_PATCH_FILES).map((f) => {
        return fs.promises.unlink(f.full).catch(() => { });
    }));
}
// Atomic write: write to a temp file then rename to avoid torn reads
// from the bot process reading a partially-written patch.
// If content is empty, deletes any existing file so stale patches from
// previous turns aren't re-uploaded by the bot.
async function writePatch({ dir, name, content, }) {
    const target = path.join(dir, name);
    if (!content) {
        await fs.promises.unlink(target).catch(() => { });
        return;
    }
    await fs.promises.mkdir(dir, { recursive: true });
    const tmp = `${target}.tmp.${process.pid}`;
    await fs.promises.writeFile(tmp, content);
    await fs.promises.rename(tmp, target);
    await prunePatchDir(dir);
}
const diffPatchPlugin = async (input) => {
    const dataDir = process.env.KIMAKI_DATA_DIR || path.join(process.env.HOME || '~', '.kimaki');
    const dir = path.join(dataDir, 'diffs');
    // session → file → { first snapshot, last snapshot }
    const sessions = new Map();
    // session → messageId → file → { first, last }
    const messages = new Map();
    // callID → pending tool info (for matching before/after)
    const pending = new Map();
    // session → current active assistant messageId
    const activeMessage = new Map();
    // dedup guard: "sessionID:messageID:completedTimestamp"
    const completed = new Set();
    // child session → root (top-level) parent session.
    // When a task tool spawns a subtask, tools inside that subtask fire with
    // the child sessionID. We resolve up to the root parent so file snapshots
    // always roll up into the top-level session's cumulative patch.
    const childToRoot = new Map();
    /** Resolve up the parent chain to find the root (top-level) session. */
    function resolveRoot(sessionID) {
        return childToRoot.get(sessionID) ?? sessionID;
    }
    function ensureSession(sessionID) {
        if (!sessions.has(sessionID)) {
            sessions.set(sessionID, new Map());
        }
        if (!messages.has(sessionID)) {
            messages.set(sessionID, new Map());
        }
    }
    function ensureMessage(sessionID, messageID) {
        const msgs = messages.get(sessionID);
        if (!msgs.has(messageID)) {
            msgs.set(messageID, new Map());
        }
    }
    function setFirst(map, file, content) {
        if (!map.has(file)) {
            map.set(file, { first: content, last: content });
        }
    }
    function prunePending() {
        const now = Date.now();
        for (const [id, item] of pending) {
            if (now - item.createdAt > PENDING_TTL_MS) {
                pending.delete(id);
            }
        }
    }
    function cleanupSession(sessionID) {
        sessions.delete(sessionID);
        messages.delete(sessionID);
        activeMessage.delete(sessionID);
        for (const [id, p] of pending) {
            if (p.sessionID === sessionID) {
                pending.delete(id);
            }
        }
        for (const key of completed) {
            if (key.startsWith(sessionID + ':')) {
                completed.delete(key);
            }
        }
        // Clean up child→root mappings pointing to or from this session
        for (const [child, root] of childToRoot) {
            if (child === sessionID || root === sessionID) {
                childToRoot.delete(child);
            }
        }
    }
    return {
        'tool.execute.before': async ({ tool, sessionID, callID }, { args }) => {
            if (!TOOLS.has(tool)) {
                return;
            }
            const raw = extractPaths(tool, args);
            if (!raw.length) {
                return;
            }
            prunePending();
            // Resolve root session so child edits roll up into the parent's patch.
            const rootID = resolveRoot(sessionID);
            ensureSession(rootID);
            const rootFiles = sessions.get(rootID);
            const msgID = activeMessage.get(rootID);
            const captured = [];
            for (const r of raw) {
                const p = path.isAbsolute(r)
                    ? r
                    : path.resolve(input.directory, r);
                const content = await readSafe(p);
                if (content === undefined) {
                    continue;
                }
                setFirst(rootFiles, p, content);
                if (msgID) {
                    ensureMessage(rootID, msgID);
                    setFirst(messages.get(rootID).get(msgID), p, content);
                }
                captured.push(p);
            }
            if (captured.length) {
                pending.set(callID, {
                    // Store rootID so tool.execute.after updates the correct maps
                    sessionID: rootID,
                    messageID: msgID,
                    paths: captured,
                    createdAt: Date.now(),
                });
            }
        },
        'tool.execute.after': async ({ callID }) => {
            prunePending();
            const call = pending.get(callID);
            if (!call) {
                return;
            }
            pending.delete(callID);
            const sessionFiles = sessions.get(call.sessionID);
            // Use messageID captured at before-time, not current activeMessage
            const msgFiles = call.messageID
                ? messages.get(call.sessionID)?.get(call.messageID)
                : undefined;
            for (const p of call.paths) {
                const content = await readSafe(p);
                if (content === undefined) {
                    continue;
                }
                const s = sessionFiles.get(p);
                if (s) {
                    s.last = content;
                }
                if (msgFiles) {
                    const m = msgFiles.get(p);
                    if (m) {
                        m.last = content;
                    }
                }
            }
        },
        event: async ({ event }) => {
            // Track child→root session mapping from session.updated events.
            // OpenCode emits session.updated with parentID set when a task tool
            // creates a child session. This fires BEFORE the child starts executing
            // tools, so the mapping is ready when child tool.execute.before fires.
            if (event.type === 'session.updated') {
                const info = event.properties.info;
                if (info.id && info.parentID) {
                    const rootID = resolveRoot(info.parentID);
                    childToRoot.set(info.id, rootID);
                }
            }
            if (event.type === 'message.updated' &&
                event.properties.info.role === 'assistant') {
                const msg = event.properties.info;
                activeMessage.set(msg.sessionID, msg.id);
                if (msg.time.completed) {
                    const key = `${msg.sessionID}:${msg.id}:${msg.time.completed}`;
                    if (completed.has(key)) {
                        return;
                    }
                    completed.add(key);
                    const msgFiles = messages.get(msg.sessionID)?.get(msg.id);
                    if (msgFiles?.size) {
                        const patch = buildPatch(msgFiles);
                        await writePatch({
                            dir,
                            name: `${msg.sessionID}-${msg.id}.patch`,
                            content: patch,
                        });
                    }
                    messages.get(msg.sessionID)?.delete(msg.id);
                }
                return;
            }
            if (event.type === 'session.idle') {
                const sessionID = event.properties.sessionID;
                // Child sessions: edits already rolled up into root, skip patch write.
                if (childToRoot.has(sessionID)) {
                    return;
                }
                const sessionFiles = sessions.get(sessionID);
                if (sessionFiles?.size) {
                    const patch = buildPatch(sessionFiles);
                    await writePatch({
                        dir,
                        name: `${sessionID}.patch`,
                        content: patch,
                    });
                    // Reset first snapshots to current state so next turn is incremental
                    for (const state of sessionFiles.values()) {
                        state.first = state.last;
                    }
                }
                messages.get(sessionID)?.clear();
                activeMessage.delete(sessionID);
                for (const [id, p] of pending) {
                    if (p.sessionID === sessionID) {
                        pending.delete(id);
                    }
                }
                // Clear completed dedup keys for this session to bound memory growth
                for (const key of completed) {
                    if (key.startsWith(sessionID + ':')) {
                        completed.delete(key);
                    }
                }
                return;
            }
            if (event.type === 'session.deleted') {
                cleanupSession(event.properties.info.id);
            }
        },
    };
};
export default diffPatchPlugin;
