/**
 * question-broker-bridge.ts — the CLIENT-side `canUseTool` seam for the
 * mobile-question-inbox (Phase B). The load-bearing universal interceptor:
 * it sits at the Agent SDK permission boundary, so it covers EVERY runtime
 * tier (stdio/IPC default, CF Sandbox, in-DO HTTPS) transport-agnostically.
 *
 * Flow for an `AskUserQuestion` tool call:
 *   1. POST {tool_use_id, world/session, payload, nonce} to the broker
 *      (`POST /v1/questions`, operator bearer). Registration is best-effort
 *      (Decision #4: only the ANSWER path fails closed) — a broker error here
 *      simply means we fall through to the local picker.
 *   2. AWAIT the operator's answer by polling `GET /v1/questions/:id/answer`
 *      until status flips to 'answered', bounded by an AbortSignal timeout
 *      (default ~120s, configurable). The SDK already passes an
 *      `options.signal` that aborts when the whole query() is cancelled; we
 *      compose BOTH signals so a query abort OR our own timeout ends the wait.
 *   3. On a matching answer, return `{ behavior: 'allow', updatedInput:<answer>,
 *      toolUseID }` so the SDK applies the operator's chosen answer as the tool
 *      input.
 *   4. On timeout / network-fail / broker-down / **toolUseId mismatch**, return
 *      a fall-through result that lets the SDK's own AskUserQuestion local
 *      picker run. A down broker MUST NEVER hang the session (T3 / Decision #6).
 *
 * SECURITY GATES implemented here:
 *
 *   F2 (T8, client half) — the answer is bound end-to-end to the EXACT
 *     toolUseId. The await registers under `options.toolUseID`; the broker only
 *     ever returns the answer for that exact id (`GET /v1/questions/:id/answer`,
 *     broker half). Before resolving the callback we ADDITIONALLY verify the
 *     returned `tool_use_id === options.toolUseID` — a defence-in-depth assert
 *     that rejects ANY cross-question/cross-session answer delivery. A
 *     mismatched id is treated as a non-answer → local-picker fallback (never
 *     a silent mis-steer where "delete database" inherits "choose theme").
 *
 *   T3 / Decision #6 — a down/slow broker never hangs the session. Every await
 *     is AbortSignal-bounded; on ANY failure mode the decision is 'ask'
 *     (fall through to the local picker), so the session is never worse off
 *     than today.
 *
 * Pure-core / impure-shell split (so F2 + the fallback logic are unit-tested
 * without HTTP or the real SDK):
 *   - `decideFromBrokerAnswer()` — pure: maps a broker poll result to a typed
 *     `BridgeDecision`. This is where the F2 toolUseId verification lives.
 *   - `awaitBrokerAnswer()` — impure: register + poll loop, fetch injected.
 *   - `makeAskUserQuestionCanUseTool()` — the SDK-shaped factory that maps a
 *     `BridgeDecision` to a real `PermissionResult`.
 */
/**
 * Pure: map a broker poll response to a `BridgeDecision`, enforcing F2.
 *
 * The answer is accepted ONLY when:
 *   - status === 'answered', AND
 *   - the returned tool_use_id === the toolUseId WE submitted (F2 client half),
 *     AND
 *   - the answer payload is a plain object (usable as `updatedInput`).
 *
 * Any other shape → 'ask' with a precise reason (the caller logs it loud so
 * the inbox can show "fell back to terminal" — never a silent downgrade).
 *
 * A `tool_use_id` that does not match is the cross-question race the gate F2
 * exists to stop: we refuse it and fall through to the local picker rather
 * than apply an answer meant for a different tool call.
 */
export function decideFromBrokerAnswer(submittedToolUseId, poll) {
    if (poll.status !== 'answered') {
        return { kind: 'ask', reason: 'not_answered' };
    }
    // F2 — the answer MUST be for the exact tool call we registered. A broker
    // (or a bug, or an attacker who somehow forced a different row) returning a
    // mismatched id is rejected here: never resolve an await for X with an
    // answer minted for Y.
    if (typeof poll.tool_use_id !== 'string' ||
        poll.tool_use_id !== submittedToolUseId) {
        return { kind: 'ask', reason: 'tool_use_id_mismatch' };
    }
    const answer = poll.answer;
    if (answer === null || typeof answer !== 'object' || Array.isArray(answer)) {
        return { kind: 'ask', reason: 'malformed_answer' };
    }
    return { kind: 'allow', answer: answer };
}
export const DEFAULT_TIMEOUT_MS = 120_000;
export const DEFAULT_POLL_INTERVAL_MS = 1_000;
function defaultSleep(ms, signal) {
    return new Promise((resolve) => {
        if (signal?.aborted) {
            resolve();
            return;
        }
        const t = setTimeout(() => {
            signal?.removeEventListener('abort', onAbort);
            resolve();
        }, ms);
        const onAbort = () => {
            clearTimeout(t);
            resolve();
        };
        signal?.addEventListener('abort', onAbort, { once: true });
    });
}
function defaultNonce() {
    // crypto.randomUUID is available in Workers, Node ≥16, and modern browsers.
    // Fall back to a time+random token where it is somehow absent.
    const c = globalThis.crypto;
    if (c?.randomUUID)
        return c.randomUUID();
    return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function defaultOnFallback(info) {
    // LOUD: every local-picker fallback is surfaced (downgrade-oracle defence /
    // T11 spirit). The inbox/observability can key on `question.broker_timeout`.
    // eslint-disable-next-line no-console
    console.error(JSON.stringify({
        event: info.reason === 'timeout'
            ? 'question.broker_timeout'
            : 'question.broker_fallback',
        tool_use_id: info.toolUseId,
        reason: info.reason,
    }));
}
/**
 * Register the question, then await the operator's answer (bounded by the
 * AbortSignal/timeout). Returns a `BridgeDecision`.
 *
 * Registration is best-effort: a non-2xx register response does NOT abort the
 * await — we still poll, because another seam (e.g. the cloud observe-tee) may
 * have registered the same toolUseId. But a hard network failure on register
 * with no chance of a row existing short-circuits to 'ask' immediately so we
 * don't burn the full timeout on a guaranteed-empty poll.
 *
 * `signal` (from the SDK's `options.signal`) composes with the internal
 * timeout: whichever fires first ends the await. The session is NEVER hung —
 * the worst case is `timeoutMs` then a local-picker fallback.
 */
export async function awaitBrokerAnswer(input, config) {
    const fetchImpl = config.fetchImpl ?? globalThis.fetch;
    const sleep = config.sleepImpl ?? defaultSleep;
    const nonce = (config.nonceImpl ?? defaultNonce)();
    const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
    const onFallback = config.onFallback ?? defaultOnFallback;
    const base = config.brokerBaseUrl.replace(/\/+$/, '');
    // Compose the SDK abort signal with our own timeout into one controller.
    const timeoutController = new AbortController();
    const composite = timeoutController.signal;
    const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
    const forwardAbort = () => timeoutController.abort();
    if (input.signal) {
        if (input.signal.aborted)
            timeoutController.abort();
        else
            input.signal.addEventListener('abort', forwardAbort, { once: true });
    }
    const finish = (decision) => {
        clearTimeout(timer);
        input.signal?.removeEventListener('abort', forwardAbort);
        if (decision.kind === 'ask') {
            onFallback({ toolUseId: input.toolUseId, reason: decision.reason });
        }
        return decision;
    };
    // ── Step 1: register (best-effort). ──────────────────────────────────────
    try {
        await fetchImpl(`${base}/v1/questions`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: config.bearer,
            },
            body: JSON.stringify({
                tool_use_id: input.toolUseId,
                world_id: config.worldId,
                session_id: config.sessionId,
                payload: input.payload ?? null,
                nonce,
            }),
            signal: composite,
        });
        // We intentionally do NOT branch on the register status: a 429 (flood) or
        // 403 (not-owned) still leaves us to poll (another seam may own the row),
        // and the poll's own not_found handling drives the eventual fallback.
    }
    catch {
        // Hard network failure on register: the broker is unreachable. Don't burn
        // the timeout — fall through to the local picker now.
        return finish({ kind: 'ask', reason: 'broker_error' });
    }
    // ── Step 2: poll for the answer until answered / aborted / timed-out. ─────
    // world_id scopes the read to the owning world (intra-tenant isolation, F1
    // read half): the broker rejects a poll whose world_id != the one the
    // question was registered under. We registered under config.worldId above.
    const answerUrl = `${base}/v1/questions/${encodeURIComponent(input.toolUseId)}/answer?world_id=${encodeURIComponent(config.worldId)}`;
    // eslint-disable-next-line no-constant-condition
    while (true) {
        if (composite.aborted) {
            // Distinguish our timeout from an external (query) abort for the reason
            // string, but both fall through to the local picker.
            const reason = input.signal?.aborted
                ? 'broker_error'
                : 'timeout';
            return finish({ kind: 'ask', reason });
        }
        let poll = null;
        try {
            const res = await fetchImpl(answerUrl, {
                method: 'GET',
                headers: { Authorization: config.bearer },
                signal: composite,
            });
            if (res.ok) {
                poll = (await res.json());
            }
            // Non-2xx (e.g. 404 not-found before another seam registered, or a
            // transient 5xx) → keep polling until the deadline; do NOT give up early.
        }
        catch {
            // Transient fetch error mid-poll → keep trying until the deadline.
            poll = null;
        }
        if (poll !== null) {
            const decision = decideFromBrokerAnswer(input.toolUseId, poll);
            if (decision.kind === 'allow') {
                return finish(decision);
            }
            // A 'tool_use_id_mismatch' is a hard stop — never apply, fall back now.
            if (decision.reason === 'tool_use_id_mismatch') {
                return finish(decision);
            }
            // 'not_answered' / 'malformed_answer' → keep polling (still pending).
        }
        await sleep(pollIntervalMs, composite);
    }
}
/**
 * Build the SDK-shaped `canUseTool` callback. It intercepts ONLY
 * `AskUserQuestion`; every other tool falls through unchanged (allow with the
 * original input — the bridge is not a general permission gate, it is the
 * question-answer seam).
 *
 * The returned callback matches the SDK `CanUseTool` shape:
 *   (toolName, input, { signal, toolUseID, … }) => Promise<PermissionResult>
 *
 * F2 wiring: the await registers under `options.toolUseID`, and the resolved
 * `PermissionResult.toolUseID` is set to that same id. The SDK matches the
 * result back to the originating tool call by id — so a result built for one
 * toolUseID can never satisfy another.
 */
/**
 * Map a stored broker answer onto the Agent SDK's AskUserQuestion result shape.
 *
 * The SDK expects `updatedInput = { questions: <original questions>, answers:
 * { "<question text>": "<selected label or labels>" } }` (clarifying-questions
 * contract). The broker stores the operator's selections as the batch
 * `{ answers: [{ questionId: "q<i>", value: "<label>", index }], toolUseId }`
 * (or a legacy single value). This pure mapper reconciles the two using the
 * original tool `input` (which carries the question text + options).
 *
 * Pure + exported for unit testing.
 */
export function buildAskUserQuestionResult(input, brokerAnswer) {
    const questions = Array.isArray(input.questions)
        ? (input.questions)
        : [];
    // Normalise the broker answer to a list of { questionId, value, index? } items.
    let items = [];
    if (Array.isArray(brokerAnswer)) {
        items = brokerAnswer;
    }
    else if (brokerAnswer != null &&
        typeof brokerAnswer === 'object' &&
        Array.isArray(brokerAnswer.answers)) {
        items = brokerAnswer.answers;
    }
    const answers = {};
    questions.forEach((q, i) => {
        const qObj = (q ?? {});
        const text = qObj.question != null ? String(qObj.question) : `q${i}`;
        const item = items.find((a) => a && a.questionId === `q${i}`);
        let label;
        if (item && item.value != null) {
            label = String(item.value);
        }
        else if (i === 0 &&
            items.length === 0 &&
            (typeof brokerAnswer === 'string' || typeof brokerAnswer === 'number')) {
            // Legacy single-value answer applies to the first question.
            label = String(brokerAnswer);
        }
        else {
            const opts = Array.isArray(qObj.options) ? qObj.options : [];
            const first = opts[0];
            label =
                typeof first === 'string'
                    ? first
                    : first && first.label != null
                        ? String(first.label)
                        : '';
        }
        answers[text] = label;
    });
    return { questions, answers };
}
export function makeAskUserQuestionCanUseTool(config, deps = {}) {
    const awaitImpl = deps.awaitImpl ?? awaitBrokerAnswer;
    return async (toolName, input, options) => {
        // Only intercept AskUserQuestion; let everything else proceed untouched.
        if (toolName !== 'AskUserQuestion') {
            return { behavior: 'allow', toolUseID: options.toolUseID };
        }
        const decision = await awaitImpl({
            toolUseId: options.toolUseID,
            payload: input,
            signal: options.signal,
        }, config);
        if (decision.kind === 'allow') {
            // Build the EXACT shape the Agent SDK applies for AskUserQuestion:
            //   { questions: <original questions>, answers: { "<question text>": "<label>" } }
            // (per the SDK clarifying-questions contract). The broker stores the
            // operator's selections as a batch ({answers:[{questionId,value,index}]});
            // map each back onto its question text using the original `input`. Without
            // this transform the model can't apply the answer and the session stalls.
            // toolUseID is echoed so the SDK binds the result to the originating call.
            return {
                behavior: 'allow',
                updatedInput: buildAskUserQuestionResult(input, decision.answer),
                toolUseID: options.toolUseID,
            };
        }
        // Fall through to the local picker: allow WITHOUT updatedInput ⇒ the SDK
        // runs AskUserQuestion's own interactive prompt. A down broker never hangs
        // the session (T3 / Decision #6).
        return { behavior: 'allow', toolUseID: options.toolUseID };
    };
}
//# sourceMappingURL=question-broker-bridge.js.map