import { nanoid } from "nanoid";
import { runInBootstrapFrame } from "./asyncContext.js";
import { AgencyCancelledError, HandlerRecursionError, RestoreSignal, } from "./errors.js";
import { applyOverrides } from "./rewind.js";
import { createReturnObject, deepClone } from "./utils.js";
import { isIpcMode, sendInterruptToParent } from "./ipc.js";
export function approve(value) {
    return { type: "approve", value };
}
export function reject(value) {
    return { type: "reject", value };
}
export function interrupt(opts) {
    return {
        type: "interrupt",
        kind: opts.kind,
        message: opts.message,
        origin: opts.origin,
        interruptId: opts.interruptId || nanoid(),
        data: opts.data,
        runId: opts.runId,
    };
}
export function createDebugInterrupt(data, checkpointId, checkpoint, runId) {
    return {
        type: "interrupt",
        kind: "debug",
        message: "",
        origin: "",
        interruptId: nanoid(),
        data,
        debugger: true,
        checkpointId,
        checkpoint,
        runId,
    };
}
export function isInterrupt(obj) {
    return obj && obj.type === "interrupt";
}
export function hasInterrupts(data) {
    return Array.isArray(data) && data.length > 0 && data.every(isInterrupt);
}
export function isDebugger(obj) {
    return isInterrupt(obj) === true && obj.debugger === true;
}
export function isRejected(obj) {
    return obj && obj.type === "reject";
}
export function isApproved(obj) {
    return obj && obj.type === "approve";
}
/** Maximum nested-dispatch depth for `runHandlerChain`. Each entry increments
 *  `ctx._handlerChainDepth`; exceeding this limit throws `HandlerRecursionError`.
 *  Picked to be well above any plausible legitimate nesting (a handler that calls
 *  one nested handler-aware operation, that itself calls another, etc.) but
 *  small enough that a runaway recursion is caught immediately rather than after
 *  hundreds of leaked handlers. See the case study in
 *  https://ampcode.com/threads/T-019e7a80-0a51-75ce-840e-89b5f595da5c where the
 *  unguarded recursion grew to ~500 handlers before the user noticed the freeze. */
const MAX_HANDLER_CHAIN_DEPTH = 10;
/** Run all registered handlers for an interrupt (top of the stack first).
 * Emits handlerDecision/interruptResolved events along the way and returns
 * a summary outcome the caller uses to decide whether to propagate. */
async function runHandlerChain(ctx, stack, interruptId, interruptObj) {
    let approvedValue = undefined;
    let hasApproval = false;
    let hasPropagation = false;
    // Increment BEFORE the limit check so a handler that calls into another
    // dispatch sees one extra level on entry; decrement in finally so even an
    // early throw (e.g. cancellation) leaves the counter clean.
    ctx._handlerChainDepth = (ctx._handlerChainDepth ?? 0) + 1;
    if (ctx._handlerChainDepth > MAX_HANDLER_CHAIN_DEPTH) {
        // Decrement before throwing so the counter reflects what's actually on
        // the stack — otherwise a caller that catches this error would see a
        // stale +1 and trip the limit on the next legitimate dispatch.
        ctx._handlerChainDepth -= 1;
        throw new HandlerRecursionError(interruptObj.kind, MAX_HANDLER_CHAIN_DEPTH);
    }
    const chainSpanId = ctx.statelogClient.startSpan("handlerChain");
    try {
        for (let i = (ctx.handlers ?? []).length - 1; i >= 0; i--) {
            if (ctx.isCancelled(stack))
                throw new AgencyCancelledError();
            // Treat handler execution as atomic for the debugger — same as LLM tool calls.
            ctx.enterToolCall();
            let result;
            try {
                result = await ctx.handlers[i](interruptObj);
            }
            finally {
                ctx.exitToolCall();
            }
            if (result === undefined) {
                ctx.statelogClient.handlerDecision({ interruptId, handlerIndex: i, decision: "none" });
                continue;
            }
            if (result.type === "reject") {
                ctx.statelogClient.handlerDecision({ interruptId, handlerIndex: i, decision: "reject", value: result.value });
                ctx.statelogClient.interruptResolved({ interruptId, outcome: "rejected", resolvedBy: "handler" });
                return { kind: "rejected", value: result.value };
            }
            if (result.type === "propagate") {
                ctx.statelogClient.handlerDecision({ interruptId, handlerIndex: i, decision: "propagate" });
                hasPropagation = true;
                continue;
            }
            if (result.type === "approve") {
                ctx.statelogClient.handlerDecision({ interruptId, handlerIndex: i, decision: "approve", value: result.value });
                hasApproval = true;
                approvedValue = result.value;
                continue;
            }
            throw new Error(`Handler returned invalid result type: ${JSON.stringify(result)}. Expected "approve", "reject", "propagate", or undefined.`);
        }
    }
    finally {
        ctx.statelogClient.endSpan(chainSpanId); // end handlerChain span
        // Always decrement, even on throw/cancel, so the counter mirrors the
        // actual nesting on the stack. Paired with the increment above.
        ctx._handlerChainDepth -= 1;
    }
    if (hasPropagation)
        return { kind: "propagated" };
    if (hasApproval)
        return { kind: "approved", value: approvedValue };
    return { kind: "noResponse" };
}
export async function interruptWithHandlers(kind, message, data, origin, ctx, stack) {
    const interruptObj = { kind, message, data, origin };
    const interruptId = nanoid();
    const outcome = await runHandlerChain(ctx, stack, interruptId, interruptObj);
    if (outcome.kind === "rejected") {
        return { type: "reject", value: outcome.value };
    }
    const hasPropagation = outcome.kind === "propagated";
    const hasApproval = outcome.kind === "approved";
    const approvedValue = hasApproval ? outcome.value : undefined;
    // IPC mode: always consult parent (unless local handler rejected — that already returned above)
    if (isIpcMode()) {
        const parentDecision = await sendInterruptToParent({ kind, message, data, origin }, { propagated: hasPropagation });
        if (parentDecision.type === "approve") {
            ctx.statelogClient.interruptResolved({
                interruptId,
                outcome: "approved",
                resolvedBy: "ipc",
            });
            return { type: "approve", value: parentDecision.value ?? approvedValue };
        }
        ctx.statelogClient.interruptResolved({
            interruptId,
            outcome: "rejected",
            resolvedBy: "ipc",
        });
        return { type: "reject", value: parentDecision.value };
    }
    // Normal mode (non-IPC)
    // Note: checkpointCreated for these interrupts is emitted by the generated
    // interrupt template code, not here. This is a known gap — non-tool
    // interrupts will have interruptThrown but no matching checkpointCreated
    // from the runtime. A future improvement could add checkpoint events to
    // the generated templates.
    if (hasPropagation) {
        const intr = interrupt({ kind, message, data, origin, runId: ctx.getRunId(), interruptId });
        ctx.statelogClient.interruptThrown({
            interruptId: intr.interruptId,
            interruptData: data,
        });
        return [intr];
    }
    if (hasApproval) {
        ctx.statelogClient.interruptResolved({
            interruptId,
            outcome: "approved",
            resolvedBy: "handler",
        });
        return { type: "approve", value: approvedValue };
    }
    // No handler responded — propagate to user
    const intr = interrupt({ kind, message, data, origin, runId: ctx.getRunId(), interruptId });
    ctx.statelogClient.interruptThrown({
        interruptId: intr.interruptId,
        interruptData: data,
    });
    return [intr];
}
/** Build the ID-keyed response map for `respondToInterrupts`. Extracted
 * to keep the main function under the structural lint limit. */
function buildResponseMap(interrupts, responses) {
    if (responses.length !== interrupts.length) {
        throw new Error(`respondToInterrupts: expected ${interrupts.length} responses but got ${responses.length}`);
    }
    const responseMap = {};
    for (let i = 0; i < interrupts.length; i++) {
        responseMap[interrupts[i].interruptId] = {
            response: deepClone(responses[i]),
        };
    }
    return responseMap;
}
/** Inner resume loop: runs the graph and reacts to RestoreSignal until the
 * agent either returns or yields another batch of interrupts. */
async function runResumeLoop(execCtx, startNodeName, agentStartTime, moduleDir) {
    let nodeName = startNodeName;
    while (true) {
        try {
            // Seed an ALS frame so stdlib helpers and `callHook` reads (which
            // post-ALS-migration look up `ctx` / `stack` / `threads` via
            // `getRuntimeContext()`) see the resumed run's context.
            //
            // This is a bootstrap frame: it only covers the small slice of
            // execution between entering `graph.run` and the first
            // `Runner.runInScope` inside the resumed node body — generated
            // node bodies re-install ALS with the actual per-node
            // `ThreadStore` (reconstituted by `setupNode` from
            // `stack.threads` JSON) on every step. So the threads slot here
            // is intentionally a `BootstrapThreadStore` — if anything inside
            // graph dispatch / setupNode tries to reach for it, the throw
            // surfaces the bug instead of letting a write silently land in
            // a discarded placeholder.
            const result = await runInBootstrapFrame(execCtx, () => execCtx.graph.run(nodeName, { data: {}, ctx: execCtx, isResume: true }, {
                onNodeEnter: (id) => execCtx.stateStack.nodesTraversed.push(id),
                statelogClient: execCtx.statelogClient,
            }), { moduleDir });
            await execCtx.pendingPromises.awaitAll();
            const returnObject = createReturnObject({ result, globals: execCtx.globals });
            if (hasInterrupts(returnObject.data)) {
                await execCtx.pauseTraceWriter();
            }
            else {
                execCtx.statelogClient.agentEnd({
                    entryNode: nodeName,
                    result: returnObject.data,
                    timeTaken: performance.now() - agentStartTime,
                    tokenStats: returnObject.tokens,
                });
                await execCtx.closeTraceWriter();
            }
            return returnObject;
        }
        catch (e) {
            if (e instanceof RestoreSignal) {
                const cp = e.checkpoint;
                execCtx._restoreCount++;
                execCtx.statelogClient.checkpointRestored({
                    checkpointId: cp.id,
                    restoreCount: execCtx._restoreCount,
                });
                execCtx.restoreState(cp);
                nodeName = cp.nodeId;
                execCtx.stateStack.nodesTraversed = [cp.nodeId];
                continue;
            }
            throw e;
        }
    }
}
export async function respondToInterrupts(args) {
    const { ctx, interrupts, responses, metadata = {} } = args;
    const responseMap = buildResponseMap(interrupts, responses);
    // All interrupts share the same checkpoint — grab from first
    const interrupt = deepClone(interrupts[0]);
    const checkpoint = interrupt.checkpoint ??
        (interrupt.checkpointId !== undefined
            ? ctx.checkpoints?.get(interrupt.checkpointId)
            : undefined);
    if (!checkpoint) {
        throw new Error("No checkpoint found for interrupt. The interrupt may have been created with an older format.");
    }
    if (args.overrides)
        applyOverrides(checkpoint, args.overrides);
    const execCtx = await ctx.createExecutionContext(interrupt.runId);
    // This is the first restore on this execCtx — record it as such.
    execCtx._restoreCount++;
    execCtx.statelogClient.checkpointRestored({
        checkpointId: checkpoint.id,
        restoreCount: execCtx._restoreCount,
    });
    // Each user response resolves a previously-thrown interrupt. Emit the
    // lifecycle event so dashboards can pair every interruptThrown with a
    // terminal interruptResolved.
    for (let i = 0; i < interrupts.length; i++) {
        const intr = interrupts[i];
        const resp = responses[i];
        const outcome = resp.type === "approve" ? "approved" : "rejected";
        execCtx.statelogClient.interruptResolved({
            interruptId: intr.interruptId,
            outcome,
            resolvedBy: "user",
        });
    }
    // Re-register top-level callbacks BEFORE restoreState so the
    // `_callbackImpl` routing check (`stateStack.isGlobalContext()`)
    // sees the still-empty stack and pushes onto `ctx.topLevelCallbacks`.
    // After `restoreState`, the stack carries the checkpoint frames and
    // the same registration would instead bind to a caller frame and be
    // popped immediately as the restored frames unwind.
    //
    // The bootstrap frame mirrors `runNode` — top-level callback
    // registration runs Agency code that goes through `__call`, which
    // reads ctx/threads/stack from ALS after the
    // drop-per-call-context-plumbing migration. See lib/runtime/node.ts
    // and lib/runtime/asyncContext.ts (`runInBootstrapFrame`).
    if (args.registerTopLevelCallbacks) {
        await runInBootstrapFrame(execCtx, () => args.registerTopLevelCallbacks(execCtx), { moduleDir: args.moduleDir });
    }
    execCtx.restoreState(checkpoint);
    execCtx.setInterruptResponses(responseMap);
    if (metadata.callbacks)
        Object.assign(execCtx.callbacks, metadata.callbacks);
    if (metadata.debugger)
        execCtx.debuggerState = metadata.debugger;
    const agentRunSpanId = execCtx.statelogClient.startSpan("agentRun");
    execCtx.statelogClient.agentStart({ entryNode: checkpoint.nodeId, args: {} });
    const agentStartTime = performance.now();
    try {
        return await runResumeLoop(execCtx, checkpoint.nodeId, agentStartTime, args.moduleDir);
    }
    catch (error) {
        const errorMessage = error instanceof Error ? error.message : String(error);
        execCtx.statelogClient.error({ errorType: "runtimeError", message: errorMessage });
        execCtx.statelogClient.agentEnd({
            entryNode: checkpoint.nodeId,
            timeTaken: performance.now() - agentStartTime,
        });
        throw error;
    }
    finally {
        execCtx.statelogClient.endSpan(agentRunSpanId); // end agentRun span
        execCtx.cleanup();
    }
}
