// OpenCode plugin for interrupting queued user messages at the next assistant
// step boundary, with a hard timeout as fallback.
// Tracks only whether each user message has started processing by
// correlating assistant message parentID events.
//
// State design: all mutable state (pending messages, recovery locks, event
// waiters, latest assistant IDs) is encapsulated in a closure-based factory
// (createInterruptState). The plugin hooks only interact with the returned
// API — they cannot directly touch Maps/Sets or break invariants like
// forgetting to clear a timer.
const DEFAULT_INTERRUPT_STEP_TIMEOUT_MS = 3_000;
function getInterruptStepTimeoutMsFromEnv() {
    const raw = process.env['KIMAKI_INTERRUPT_STEP_TIMEOUT_MS'];
    if (!raw) {
        return DEFAULT_INTERRUPT_STEP_TIMEOUT_MS;
    }
    const parsed = Number.parseInt(raw, 10);
    if (!Number.isFinite(parsed) || parsed <= 0) {
        return DEFAULT_INTERRUPT_STEP_TIMEOUT_MS;
    }
    return parsed;
}
// ── Encapsulated interrupt state ─────────────────────────────────
// All 4 mutable variables (pendingByMessageId, latestAssistantMessageID,
// recoveringSessions, waiters) are trapped inside this closure. The plugin
// hooks only see the returned API methods — they cannot break invariants
// like forgetting to clear a timer or leaving a stale recovery lock.
function createInterruptState() {
    const pendingByMessageId = new Map();
    const latestAssistantMessageIDBySession = new Map();
    const recoveringSessions = new Set();
    const waiters = new Set();
    function clearPending(messageID) {
        const pending = pendingByMessageId.get(messageID);
        if (!pending) {
            return;
        }
        clearTimeout(pending.timer);
        pendingByMessageId.delete(messageID);
    }
    function dispatchEvent(event) {
        Array.from(waiters).forEach((waiter) => {
            if (!waiter.match(event)) {
                return;
            }
            waiter.finish();
        });
    }
    function waitForEvent(input) {
        return new Promise((resolve) => {
            const finish = (matched) => {
                clearTimeout(waiter.timer);
                waiters.delete(waiter);
                resolve(matched);
            };
            const waiter = {
                match: input.match,
                finish: () => {
                    finish(true);
                },
                timer: setTimeout(() => {
                    finish(false);
                }, input.timeoutMs),
            };
            waiters.add(waiter);
        });
    }
    function getNextPendingForSession(sessionID) {
        for (const [messageID, pending] of pendingByMessageId.entries()) {
            if (pending.sessionID !== sessionID) {
                continue;
            }
            if (pending.started) {
                continue;
            }
            return { messageID, pending };
        }
        return undefined;
    }
    return {
        dispatchEvent,
        waitForEvent,
        getNextPendingForSession,
        hasPending(messageID) {
            return pendingByMessageId.has(messageID);
        },
        getPending(messageID) {
            return pendingByMessageId.get(messageID);
        },
        // Schedule a timeout to interrupt a pending message. Cleans up any
        // existing timer for the same messageID before setting a new one.
        schedulePending({ messageID, sessionID, delayMs, onTimeout, }) {
            const existing = pendingByMessageId.get(messageID);
            if (existing) {
                clearTimeout(existing.timer);
            }
            const timer = setTimeout(onTimeout, delayMs);
            pendingByMessageId.set(messageID, {
                sessionID,
                started: false,
                timer,
                abortAfterStepMessageID: latestAssistantMessageIDBySession.get(sessionID),
                agent: undefined,
                model: undefined,
            });
        },
        markStarted(messageID) {
            const pending = pendingByMessageId.get(messageID);
            if (!pending) {
                return;
            }
            pending.started = true;
            clearPending(messageID);
        },
        clearPending,
        isRecovering(sessionID) {
            return recoveringSessions.has(sessionID);
        },
        setRecovering(sessionID) {
            recoveringSessions.add(sessionID);
        },
        clearRecovering(sessionID) {
            recoveringSessions.delete(sessionID);
        },
        setLatestAssistantMessage(sessionID, messageID) {
            latestAssistantMessageIDBySession.set(sessionID, messageID);
        },
        clearLatestAssistantMessage(sessionID) {
            latestAssistantMessageIDBySession.delete(sessionID);
        },
        // Clean up all state for a deleted session — timers, recovery locks, etc.
        cleanupSession(sessionID) {
            latestAssistantMessageIDBySession.delete(sessionID);
            Array.from(pendingByMessageId.entries()).forEach(([messageID, pending]) => {
                if (pending.sessionID !== sessionID) {
                    return;
                }
                clearPending(messageID);
            });
        },
    };
}
// ── Plugin ───────────────────────────────────────────────────────
const interruptOpencodeSessionOnUserMessage = async (ctx) => {
    const interruptStepTimeoutMs = getInterruptStepTimeoutMsFromEnv();
    const state = createInterruptState();
    async function interruptPendingMessage(messageID) {
        const pending = state.getPending(messageID);
        if (!pending) {
            state.clearPending(messageID);
            return;
        }
        if (pending.started) {
            state.clearPending(messageID);
            return;
        }
        const sessionID = pending.sessionID;
        if (state.isRecovering(sessionID)) {
            state.schedulePending({
                messageID,
                sessionID,
                delayMs: 200,
                onTimeout: () => {
                    void interruptPendingMessage(messageID);
                },
            });
            return;
        }
        state.setRecovering(sessionID);
        try {
            const abortedAssistantWait = state.waitForEvent({
                match: (event) => {
                    return (event.type === 'message.updated'
                        && event.properties.info.role === 'assistant'
                        && event.properties.info.sessionID === sessionID
                        && event.properties.info.error?.name === 'MessageAbortedError');
                },
                timeoutMs: 5_000,
            });
            const idleWait = state.waitForEvent({
                match: (event) => {
                    return event.type === 'session.idle' && event.properties.sessionID === sessionID;
                },
                timeoutMs: 10_000,
            });
            await ctx.client.session.abort({
                path: { id: sessionID },
            });
            await abortedAssistantWait;
            await idleWait;
            const currentPending = state.getPending(messageID);
            if (!currentPending || currentPending.started) {
                state.clearPending(messageID);
                return;
            }
            // Keep the queued user message execution context across abort+resume.
            // Without this, OpenCode re-resolves model defaults and can ignore
            // /model session overrides (issue #77).
            const resumeBody = { parts: [] };
            if (currentPending.agent) {
                resumeBody.agent = currentPending.agent;
            }
            if (currentPending.model) {
                resumeBody.model = currentPending.model;
            }
            await ctx.client.session.promptAsync({
                path: { id: sessionID },
                body: resumeBody,
            });
            state.clearPending(messageID);
            const nextPending = state.getNextPendingForSession(sessionID);
            if (!nextPending) {
                return;
            }
            state.schedulePending({
                messageID: nextPending.messageID,
                sessionID,
                delayMs: 50,
                onTimeout: () => {
                    void interruptPendingMessage(nextPending.messageID);
                },
            });
        }
        finally {
            state.clearRecovering(sessionID);
        }
    }
    return {
        async event({ event }) {
            state.dispatchEvent(event);
            if (event.type === 'message.part.updated' && event.properties.part.type === 'step-finish') {
                const nextPending = state.getNextPendingForSession(event.properties.part.sessionID);
                if (!nextPending) {
                    return;
                }
                if (state.isRecovering(nextPending.pending.sessionID)) {
                    return;
                }
                if (!nextPending.pending.abortAfterStepMessageID) {
                    return;
                }
                if (event.properties.part.messageID !== nextPending.pending.abortAfterStepMessageID) {
                    return;
                }
                void interruptPendingMessage(nextPending.messageID);
                return;
            }
            if (event.type === 'message.updated' && event.properties.info.role === 'assistant') {
                if (!event.properties.info.error) {
                    state.setLatestAssistantMessage(event.properties.info.sessionID, event.properties.info.id);
                }
                const nextPending = state.getNextPendingForSession(event.properties.info.sessionID);
                if (nextPending
                    && !nextPending.pending.started
                    && !event.properties.info.error
                    && event.properties.info.parentID !== nextPending.messageID) {
                    nextPending.pending.abortAfterStepMessageID = event.properties.info.id;
                }
                const parentID = event.properties.info.parentID;
                state.markStarted(parentID);
                return;
            }
            if (event.type === 'session.idle') {
                state.clearLatestAssistantMessage(event.properties.sessionID);
                return;
            }
            if (event.type === 'session.deleted') {
                state.cleanupSession(event.properties.info.id);
            }
        },
        async 'chat.message'(input, output) {
            const sessionID = input.sessionID;
            if (!sessionID) {
                return;
            }
            // Ignore empty-parts messages (e.g. our own promptAsync({ parts: [] })
            // resume calls). These are synthetic and should not trigger interruption.
            if (output.parts.length === 0) {
                return;
            }
            const messageID = input.messageID || output.message.id;
            if (!messageID) {
                return;
            }
            if (state.hasPending(messageID)) {
                return;
            }
            state.schedulePending({
                messageID,
                sessionID,
                delayMs: interruptStepTimeoutMs,
                onTimeout: () => {
                    void interruptPendingMessage(messageID);
                },
            });
            const pending = state.getPending(messageID);
            if (!pending) {
                return;
            }
            pending.agent = output.message.agent;
            pending.model = output.message.model;
        },
    };
};
export { interruptOpencodeSessionOnUserMessage };
