import { isFastResponseEligible } from "./fast-response-eligibility.js";
import { buildSystemPrompt } from "./system-prompt.js";
import { buildInterruptContext, buildInterruptWithBriefing } from "./context.js";
import { fastRespond } from "./fast-responder.js";
import { formatMessageSummary, previewIncomingForConsole, buildIncomingLogData } from "./message-format.js";
import { buildReplyTarget, buildTaskReplyTarget } from "./reply-target.js";
import { transcribeVoiceMessages } from "../utils/transcribe.js";
import { logger } from "../logger.js";
import { t, resolveLocale } from "../i18n/index.js";
import { getMessageDedupKey } from "../channels/message-dedup.js";
const INTERRUPT_DEBOUNCE_MS = 3000;
const FAST_RESPONDER_TIMEOUT_MS = 10_000;
export class InterruptHandler {
    deps;
    timer = null;
    inFlight = false;
    lastInjectedBatch = null;
    /** Message dedup keys that already received a fast response in this process. */
    fastRespondedKeys = new Set();
    /** Chat monitor channels that already injected a notification this wake cycle. */
    notifiedChatMonitorChannels = new Set();
    constructor(deps) {
        this.deps = deps;
    }
    /**
     * Clear per-cycle chat-monitor tracking. Fast-response message keys are
     * intentionally process-wide so session-switch redelivery cannot get a
     * second quick ack for the same provider message.
     */
    clearFastResponseTracking() {
        this.notifiedChatMonitorChannels.clear();
    }
    /**
     * Record chat monitor channels from the initial wake messages so that
     * duplicate notifications for the same app are suppressed during the cycle.
     */
    recordInitialChatMonitorMessages(messages) {
        for (const msg of messages) {
            if (msg.meta?.kind === "external-wake") {
                this.notifiedChatMonitorChannels.add(msg.channel);
            }
        }
    }
    filterDuplicateChatMonitorMessages(batch) {
        const kept = [];
        for (const msg of batch) {
            if (msg.meta?.kind === "external-wake" && this.notifiedChatMonitorChannels.has(msg.channel)) {
                logger.debug(`Suppressed duplicate chat monitor notification for ${msg.channel}`);
                continue;
            }
            if (msg.meta?.kind === "external-wake") {
                this.notifiedChatMonitorChannels.add(msg.channel);
            }
            kept.push(msg);
        }
        return kept;
    }
    getFastResponseEligibleMessages(messages) {
        return messages.filter(isFastResponseEligible);
    }
    /**
     * Called from the channelManager "message" event when the agent is busy.
     * Schedules a debounced interrupt batch.
     */
    schedule() {
        if (this.timer !== null)
            return;
        this.timer = setTimeout(() => {
            this.timer = null;
            void this.processBatch();
        }, INTERRUPT_DEBOUNCE_MS);
    }
    dispose() {
        if (this.timer !== null) {
            clearTimeout(this.timer);
            this.timer = null;
        }
    }
    /**
     * Watchdog fallback: called when the primary agent hasn't replied within
     * the watchdog timeout on a message-triggered wake. Runs the fast
     * responder and injects a briefing into the active session.
     */
    async fireWatchdog(messages) {
        const agentState = this.deps.getAgentState();
        if (agentState.hasRepliedInCycle) {
            logger.debug("Watchdog skipped: agent already replied");
            return;
        }
        const sendableMessages = this.getFastResponseEligibleMessages(messages);
        if (sendableMessages.length === 0) {
            logger.debug("Watchdog skipped: no fast-response-eligible messages");
            return;
        }
        const result = await this.callFastRespondAndSend(messages, sendableMessages, "watchdog", () => !agentState.hasRepliedInCycle);
        if (!result) {
            // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async callFastRespondAndSend
            if (!agentState.hasRepliedInCycle) {
                const notifiable = this.getFastResponseEligibleMessages(messages);
                if (notifiable.length > 0) {
                    await this.sendProcessingNotification(notifiable);
                }
                agentState.hasRepliedInCycle = true;
            }
            return;
        }
        if (result.sentCount > 0) {
            agentState.hasRepliedInCycle = true;
            logger.log("info", "fast_response", `Watchdog replied to ${result.sentCount} recipient(s) for ${messages.length} message(s)`, {
                messageCount: messages.length,
                recipientCount: result.sentCount,
                needsPrimaryAction: result.fastResponse.needsPrimaryAction,
                mode: "watchdog",
            });
        }
        else {
            logger.system("Watchdog suppressed: agent replied while fast responder was running");
            return;
        }
        if (result.fastResponse.needsPrimaryAction) {
            const briefingBlocks = buildInterruptWithBriefing({
                messages,
                fastResponse: result.fastResponse,
            });
            if (!this.deps.sessionManager.activeSession.injectMessage(briefingBlocks)) {
                // Watchdog messages are the *initial* messages for the current wake
                // cycle. If the active session is already closed (for example because
                // the first LLM attempt hit prompt-too-long and the wake-cycle runner is
                // retrying/recovering), re-queuing these same messages creates a second
                // full wake after the primary retry finishes. That second wake can arm
                // the watchdog again and send another acknowledgement even though the
                // original cycle already produced the final answer.
                //
                // The watchdog has already acknowledged the user above; the briefing is
                // only a best-effort hint to the in-flight model. Dropping the briefing
                // when injection fails avoids duplicate user-visible handling while the
                // current wake cycle/retry remains responsible for the original message.
                // Trade-off: if the session is closing due to a terminal failure rather
                // than an internal retry, this no longer uses a timer race to salvage the
                // initial messages. Terminal failure handling should surface that
                // deterministically in the runner instead of re-queuing from watchdog.
                const recipients = messages
                    .map((msg) => `${msg.channel}:${msg.sender}`)
                    .join(", ");
                logger.system(`Watchdog briefing dropped: session closed before inject for ${messages.length} initial message(s) (${recipients})`);
            }
        }
    }
    requeue(batch) {
        logger.warn(`Session closed before inject — re-queuing ${batch.length} message(s) for next wake cycle`);
        for (const msg of batch) {
            this.deps.messageQueue.enqueue(msg);
        }
        this.deps.wakeMainLoop();
    }
    /**
     * If injected messages were orphaned (accepted but never delivered to SDK),
     * re-queue the original CommandMessages so they're handled on the next wake.
     */
    requeueIfOrphaned() {
        if (this.lastInjectedBatch) {
            this.requeue(this.lastInjectedBatch);
            this.lastInjectedBatch = null;
        }
    }
    /**
     * Send a generic "your message is being processed" acknowledgment to each
     * unique sender in a batch. Used as a fallback when the fast responder fails.
     */
    async sendProcessingNotification(batch) {
        const { channelManager } = this.deps;
        const agentState = this.deps.getAgentState();
        const sent = new Set();
        const locale = resolveLocale(this.deps.ownerConfig.language);
        const text = agentState.compacting
            ? t("interrupt.compacting", locale)
            : agentState.learning
                ? t("interrupt.learning", locale)
                : t("interrupt.working", locale);
        for (const msg of batch) {
            const key = `${msg.channel}::${msg.sender}`;
            if (sent.has(key))
                continue;
            sent.add(key);
            try {
                await channelManager.sendMessage(msg.channel, msg.sender, text, undefined, { messageId: msg.id, meta: msg.meta });
            }
            catch (err) {
                logger.warn(`Failed to send processing notification to ${key}: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    /**
     * Shared logic: call fastRespond with a timeout, match replies to actual
     * recipients, and send them. Returns the fast response and how many
     * recipients were sent to, or null if the call failed/timed out.
     *
     * @param shouldSend Optional guard checked before sending each reply.
     *   If it returns false, remaining sends are skipped.
     */
    async callFastRespondAndSend(batch, sendableMessages, mode = "interrupt", shouldSend) {
        const { config, ownerConfig, channelManager, activityTracker } = this.deps;
        const startMs = Date.now();
        const agentState = this.deps.getAgentState();
        const fr = await Promise.race([
            fastRespond({
                systemPrompt: buildSystemPrompt(config, ownerConfig),
                messages: batch,
                activitySummary: activityTracker.getSummary(),
                config,
                mode,
                compacting: agentState.compacting,
                learning: agentState.learning,
            }),
            new Promise((resolve) => setTimeout(() => resolve(null), FAST_RESPONDER_TIMEOUT_MS)),
        ]);
        if (!fr) {
            const elapsedMs = Date.now() - startMs;
            const reason = elapsedMs >= FAST_RESPONDER_TIMEOUT_MS ? "timeout" : "error";
            logger.warn(`Fast responder ${reason} after ${elapsedMs}ms for ${batch.length} message(s)`);
            return null;
        }
        const actualRecipients = new Map();
        for (const msg of batch) {
            const key = `${msg.channel}::${msg.sender}`;
            if (!actualRecipients.has(key)) {
                actualRecipients.set(key, { channel: msg.channel, sender: msg.sender });
            }
        }
        const replyMap = new Map();
        for (const r of fr.replies) {
            const exactKey = `${r.channel}::${r.sender}`;
            if (actualRecipients.has(exactKey)) {
                replyMap.set(exactKey, r.reply);
                continue;
            }
            for (const [ak, av] of actualRecipients) {
                if (av.channel === r.channel && !replyMap.has(ak)) {
                    replyMap.set(ak, r.reply);
                    break;
                }
            }
        }
        const contextMap = new Map();
        for (const msg of batch) {
            const key = `${msg.channel}::${msg.sender}`;
            if (!contextMap.has(key))
                contextMap.set(key, msg);
        }
        const sent = new Set();
        for (const msg of sendableMessages) {
            const key = `${msg.channel}::${msg.sender}`;
            if (sent.has(key))
                continue;
            const reply = replyMap.get(key);
            if (!reply) {
                logger.warn(`Fast responder produced no matching reply for ${key}`);
                continue;
            }
            const originalMsg = contextMap.get(key);
            const replyTo = originalMsg
                ? { messageId: originalMsg.id, meta: originalMsg.meta }
                : undefined;
            try {
                if (shouldSend && !shouldSend())
                    break;
                await channelManager.sendMessage(msg.channel, msg.sender, reply, undefined, replyTo);
                sent.add(key);
            }
            catch (sendErr) {
                logger.warn(`Failed to send fast response: ${sendErr instanceof Error ? sendErr.message : String(sendErr)}`);
            }
        }
        return { fastResponse: fr, sentCount: sent.size };
    }
    async processBatch() {
        const agentState = this.deps.getAgentState();
        if (this.inFlight || !agentState.busy || agentState.gated)
            return;
        this.inFlight = true;
        const { sessionManager, channelManager, messageQueue, } = this.deps;
        try {
            const rawBatch = [];
            while (!messageQueue.isEmpty()) {
                const msg = messageQueue.dequeue();
                if (msg)
                    rawBatch.push(msg);
            }
            if (rawBatch.length === 0)
                return;
            for (const msg of rawBatch) {
                messageQueue.markHandled(msg);
            }
            const batch = this.filterDuplicateChatMonitorMessages(rawBatch);
            if (batch.length === 0)
                return;
            for (const msg of batch) {
                agentState.replyMap.set(`${msg.channel}::${msg.sender}`, buildReplyTarget(msg));
            }
            agentState.currentTaskReplyTarget ??= buildTaskReplyTarget(batch[0]);
            // Separate messages that already received a fast response from new ones.
            const newMessages = batch.filter((m) => !this.fastRespondedKeys.has(getMessageDedupKey(m)));
            for (const msg of batch) {
                if (this.fastRespondedKeys.has(getMessageDedupKey(msg))) {
                    logger.log("info", "fast_response", "quick_response_dedup_skipped", {
                        dedupKey: getMessageDedupKey(msg),
                        messageId: msg.id,
                        channel: msg.channel,
                        sender: msg.sender,
                    });
                }
            }
            const sendableMessages = this.getFastResponseEligibleMessages(newMessages);
            const allAlreadyResponded = newMessages.length === 0;
            for (const msg of batch) {
                const isVoice = msg.attachments.some((a) => a.type === "audio");
                // Compact single-line console output. For gmail we intentionally drop
                // the structured `data` blob (no HTML noise in the console/JSONL);
                // other channels retain it so the JSONL log file stays complete.
                const baseData = buildIncomingLogData(msg);
                const data = baseData === undefined
                    ? undefined
                    : { ...baseData, interrupt: true, voice: isVoice };
                logger.log("info", "incoming", `[interrupt] [${msg.channel}]${isVoice ? " [voice]" : ""} ${msg.sender}: ${previewIncomingForConsole(msg)}`, data);
            }
            for (const msg of batch) {
                this.deps.activityTracker.recordUserMessage(formatMessageSummary(msg));
            }
            const typingSent = new Set();
            for (const msg of batch) {
                const key = `${msg.channel}::${msg.sender}`;
                if (typingSent.has(key))
                    continue;
                typingSent.add(key);
                channelManager.sendTypingIndicator(msg.channel, msg.sender).catch(() => { });
            }
            await transcribeVoiceMessages(batch);
            // Skip fast-response if every message in the batch already received one
            if (allAlreadyResponded) {
                // During Skill Learning Mode, never inject into the active session
                // (it's running a focused skill merge). Requeue for the next cycle.
                if (agentState.learning) {
                    this.requeue(batch);
                    logger.system(`Queued ${batch.length} interrupt message(s) during Skill Learning Mode.`);
                    return;
                }
                logger.debug(`All ${batch.length} message(s) already fast-responded — skipping to inject`);
                const interruptBlocks = await buildInterruptContext({ messages: batch });
                if (sessionManager.activeSession.injectMessage(interruptBlocks)) {
                    this.lastInjectedBatch = batch;
                }
                else {
                    this.requeue(batch);
                }
                return;
            }
            if (sendableMessages.length === 0) {
                // During Skill Learning Mode, never inject into the active session.
                if (agentState.learning) {
                    this.requeue(batch);
                    logger.system(`Queued ${batch.length} interrupt message(s) during Skill Learning Mode.`);
                    return;
                }
                logger.debug(`Skipping fast responder for ${batch.length} message(s): no fast-response-eligible channels`);
                const interruptBlocks = await buildInterruptContext({ messages: batch });
                if (sessionManager.activeSession.injectMessage(interruptBlocks)) {
                    this.lastInjectedBatch = batch;
                }
                else {
                    this.requeue(batch);
                }
                return;
            }
            const result = await this.callFastRespondAndSend(batch, sendableMessages);
            if (result) {
                for (const msg of batch) {
                    this.fastRespondedKeys.add(getMessageDedupKey(msg));
                }
                logger.log("info", "fast_response", `Replied to ${result.sentCount} recipient(s) for ${batch.length} message(s)`, {
                    messageCount: batch.length,
                    recipientCount: result.sentCount,
                    needsPrimaryAction: result.fastResponse.needsPrimaryAction,
                });
                // During Skill Learning Mode, the fast responder has already
                // acknowledged the user. Never inject into the active session --
                // it's running a focused skill merge. Requeue for the next cycle.
                if (agentState.learning) {
                    this.requeue(batch);
                    logger.system(`Queued ${batch.length} interrupt message(s) during Skill Learning Mode.`);
                }
                else if (result.fastResponse.needsPrimaryAction) {
                    const briefingBlocks = buildInterruptWithBriefing({
                        messages: batch,
                        fastResponse: result.fastResponse,
                    });
                    if (sessionManager.activeSession.injectMessage(briefingBlocks)) {
                        this.lastInjectedBatch = batch;
                    }
                    else {
                        this.requeue(batch);
                    }
                }
                else {
                    logger.log("info", "fast_response", `No follow-up needed for ${batch.length} message(s)`, {
                        messageCount: batch.length,
                        recipientCount: result.sentCount,
                        needsPrimaryAction: result.fastResponse.needsPrimaryAction,
                    });
                }
            }
            else {
                logger.warn(`Fast responder failed for ${batch.length} message(s) — falling back to inject`);
                // Mark as fast-responded even on timeout/failure to prevent repeated attempts
                for (const msg of batch) {
                    this.fastRespondedKeys.add(getMessageDedupKey(msg));
                }
                const notifiable = this.getFastResponseEligibleMessages(newMessages);
                if (notifiable.length > 0) {
                    await this.sendProcessingNotification(notifiable);
                }
                // During Skill Learning Mode, never inject into the active session.
                if (agentState.learning) {
                    this.requeue(batch);
                    logger.system(`Queued ${batch.length} interrupt message(s) during Skill Learning Mode.`);
                    return;
                }
                const interruptBlocks = await buildInterruptContext({ messages: batch });
                if (sessionManager.activeSession.injectMessage(interruptBlocks)) {
                    this.lastInjectedBatch = batch;
                }
                else {
                    this.requeue(batch);
                }
            }
        }
        finally {
            this.inFlight = false;
            const agentStateNow = this.deps.getAgentState();
            // Don't reschedule during Learning Mode — messages are just requeued
            // and would spin every 3s until learning ends.
            if (!messageQueue.isEmpty() && agentStateNow.busy && !agentStateNow.learning && !this.deps.sessionManager.activeSession.isInputClosed) {
                this.schedule();
            }
        }
    }
}
//# sourceMappingURL=interrupt-handler.js.map