"use strict";
/**
 * Utility for making authenticated calls to the Amplitude MCP server with
 * automatic Claude agent fallback.
 *
 * When the direct HTTP call fails (tool removed, auth error, parse failure,
 * etc.) the function re-runs the same query via a minimal Claude agent that
 * has only the Amplitude MCP server configured. This insulates callers from
 * MCP API drift without requiring them to write two code paths.
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AMPLITUDE_MCP_URL = void 0;
exports.invalidateMcpSessionCache = invalidateMcpSessionCache;
exports._clearMcpSessionCacheForTesting = _clearMcpSessionCacheForTesting;
exports.invalidateMcpSessionsForToken = invalidateMcpSessionsForToken;
exports.callAmplitudeMcp = callAmplitudeMcp;
const path_1 = __importDefault(require("path"));
const debug_js_1 = require("../utils/debug.js");
const wizard_abort_js_1 = require("../utils/wizard-abort.js");
const urls_js_1 = require("../utils/urls.js");
const constants_js_1 = require("./constants.js");
const schemas_js_1 = require("./middleware/schemas.js");
const index_js_1 = require("./observability/index.js");
/**
 * Default MCP URL when no zone is known to the caller.
 *
 * Most consumers (`agent-runner`, `agent-interface.buildDefaultAgentConfig`,
 * `addMCPServerToClientsStep`) DO know the zone and should pass `mcpUrl`
 * explicitly via `getMcpUrlFromZone(zone)`. This constant exists for the
 * narrow set of callers that genuinely have no zone context (bootstrap
 * paths, test mocks); falling back to US matches the historical behaviour.
 *
 * Honors the `MCP_URL` env override for tests / dev.
 */
exports.AMPLITUDE_MCP_URL = (0, urls_js_1.getMcpUrlFromZone)('us');
let _sdkModule = null;
async function getSDKModule() {
    if (!_sdkModule) {
        const mod = await import('@anthropic-ai/claude-agent-sdk');
        _sdkModule = { query: mod.query };
    }
    return _sdkModule;
}
function getClaudeCodeExecutablePath() {
    const sdkPackagePath = require.resolve('@anthropic-ai/claude-agent-sdk');
    return path_1.default.join(path_1.default.dirname(sdkPackagePath), 'cli.js');
}
const MCP_SESSION_TTL_MS = 5 * 60_000;
// ── Per-fetch timeouts ────────────────────────────────────────────────────────
//
// Without these caps, a hung MCP gateway pinned the wizard with no upper
// bound: `openMcpSessionInner` and the bound `callTool` closure both
// awaited bare `fetch(...)` calls relying solely on the caller's external
// abort signal. In practice that meant a hang propagated all the way up
// to the outer agent's stall timer (60s) before anything reacted,
// burning the entire SDK turn for what was usually a single hung request.
//
// Matched against the rest of the wizard's network budget:
//   - Token-refresh: 10s
//   - Gateway-liveness probe: 8s
//   - Agent fallback: 12s
//   - SSE stream stall: 60s (active phase)
// 30s for tool calls is generous (the Amplitude MCP server typically
// responds in <2s; 30s suggests something is wrong) and 5s for the
// fire-and-forget `notifications/initialized` is generous (we don't
// even use the response). The fetch is still race-cancelled by the
// caller's external signal — these caps are the upper bound, not a
// floor.
/** Tool-call + initialize round-trip cap. */
const MCP_FETCH_TIMEOUT_MS = 30_000;
/** Fire-and-forget `notifications/initialized` cap. */
const MCP_NOTIFY_TIMEOUT_MS = 5_000;
/**
 * `fetch` wrapped with a per-call timeout, covering both the request
 * round-trip AND body consumption. Combines the caller's external
 * `AbortSignal` (if any) with an internal `AbortController` so either
 * source — caller cancellation OR timeout — tears the request down
 * immediately.
 *
 * Body consumption (`res.text()`, `res.json()`, `res.body.cancel()`)
 * MUST happen inside the `consume` callback. The timer and abort
 * listener stay armed for the entire callback so a slow body read can
 * still be cancelled. Cleanup runs unconditionally in `finally` — no
 * leaked timers or listeners on the success path.
 *
 * Throws on timeout exactly the way `fetch` throws on caller-side
 * abort: an `AbortError` from the underlying controller. Callers that
 * already have `try/catch` on `fetch` need no changes; the existing
 * `[MCP] X failed: ...` log lines absorb the new error class.
 *
 * Why caller-supplied consume:
 *   Returning a bare `Response` made it ambiguous whether the timer
 *   covered the body read. Two prior implementations bounced between
 *   "cleanup in finally before body read" (body unbounded) and
 *   "cleanup only on error" (success-path leak). The callback shape
 *   resolves both — the timer is alive exactly as long as the caller
 *   is consuming the response, and dies the moment the consumer
 *   returns or throws.
 */
async function fetchWithTimeout(url, init, timeoutMs, externalSignal, consume) {
    // Bail immediately if the external signal is already aborted —
    // matches the standard `fetch` contract for already-aborted signals.
    if (externalSignal?.aborted) {
        throw new DOMException(String(externalSignal.reason ?? 'aborted'), 'AbortError');
    }
    const controller = new AbortController();
    const onExternalAbort = () => controller.abort();
    externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
    const timer = setTimeout(() => controller.abort('timeout'), timeoutMs);
    try {
        const res = await fetch(url, { ...init, signal: controller.signal });
        return await consume(res);
    }
    finally {
        clearTimeout(timer);
        externalSignal?.removeEventListener('abort', onExternalAbort);
    }
}
const mcpSessionCache = new Map();
function getCachedMcpSession(accessToken, mcpUrl) {
    const key = `${accessToken}|${mcpUrl}`;
    const entry = mcpSessionCache.get(key);
    if (!entry)
        return null;
    if (entry.expiresAt <= Date.now()) {
        mcpSessionCache.delete(key);
        return null;
    }
    return entry.callTool;
}
function cacheMcpSession(accessToken, mcpUrl, callTool) {
    const key = `${accessToken}|${mcpUrl}`;
    mcpSessionCache.set(key, {
        callTool,
        expiresAt: Date.now() + MCP_SESSION_TTL_MS,
    });
}
/**
 * Drop cached MCP sessions. Pass an `accessToken` to invalidate only
 * that token's entries (e.g. on token refresh); omit to clear every
 * cached session (e.g. on logout). The auth/token-refresh path should
 * call this so the next `callAmplitudeMcp` rehandshakes with the new
 * token instead of failing on a stale `Authorization: Bearer` header.
 */
function invalidateMcpSessionCache(accessToken) {
    if (accessToken === undefined) {
        mcpSessionCache.clear();
        return;
    }
    const prefix = `${accessToken}|`;
    for (const key of mcpSessionCache.keys()) {
        if (key.startsWith(prefix))
            mcpSessionCache.delete(key);
    }
}
/** Test-only: clear the cache between unit-test cases. */
function _clearMcpSessionCacheForTesting() {
    mcpSessionCache.clear();
}
/**
 * Drop every cached MCP session bound to `accessToken`. Call after
 * silently refreshing the OAuth token so the next `callAmplitudeMcp`
 * doesn't reuse a `callTool` closure carrying the now-expired bearer
 * (which would 401 on the upstream and force the slower agent fallback
 * for ~12s, even though we already have a fresh token in hand).
 *
 * Idempotent — no-op when no entries match.
 */
function invalidateMcpSessionsForToken(accessToken) {
    if (!accessToken)
        return;
    invalidateMcpSessionCache(accessToken);
}
/**
 * Open an MCP session and return a `callTool` helper bound to that session.
 * Returns a tagged result so callers can distinguish transient failures
 * (worth falling back to the agent path) from deterministic ones (auth
 * errors — agent fallback uses the same token and would just hit the
 * same wall). Reuses a cached session for the same `accessToken|mcpUrl`
 * pair when it's still fresh.
 */
async function openMcpSession(accessToken, mcpUrl, signal) {
    const cached = getCachedMcpSession(accessToken, mcpUrl);
    if (cached) {
        (0, debug_js_1.logToFile)('[MCP] reusing cached session');
        return { ok: true, callTool: cached };
    }
    return (0, index_js_1.withWizardSpan)('mcp.session.init', 'mcp.session', { 'mcp.url': mcpUrl }, async () => {
        const fresh = await openMcpSessionInner(accessToken, mcpUrl, signal);
        if (fresh.ok)
            cacheMcpSession(accessToken, mcpUrl, fresh.callTool);
        return fresh;
    });
}
async function openMcpSessionInner(accessToken, mcpUrl, signal) {
    const headers = {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
        'User-Agent': constants_js_1.WIZARD_USER_AGENT,
    };
    let sessionId;
    try {
        // Pull status + session id inside the consume callback so the body
        // cancel happens while the timer + external abort are still armed.
        const initOutcome = await fetchWithTimeout(mcpUrl, {
            method: 'POST',
            headers: { ...headers, Accept: 'application/json, text/event-stream' },
            body: JSON.stringify({
                jsonrpc: '2.0',
                id: 0,
                method: 'initialize',
                params: {
                    protocolVersion: '2024-11-05',
                    capabilities: {},
                    clientInfo: { name: 'amplitude-wizard', version: '1.0.0' },
                },
            }),
        }, MCP_FETCH_TIMEOUT_MS, signal, async (res) => {
            const status = res.status;
            const extractedSessionId = res.headers.get('mcp-session-id') ?? undefined;
            await res.body?.cancel().catch(() => undefined);
            return { status, sessionId: extractedSessionId };
        });
        // 401/403 mean the access token is bad. The agent fallback uses the
        // same token, so it would just hit the same wall after a 12s setup —
        // fail fast instead. 5xx is transient (gateway hiccup); fall through.
        if (initOutcome.status === 401 || initOutcome.status === 403) {
            (0, debug_js_1.logToFile)(`[MCP] initialize rejected by gateway (HTTP ${initOutcome.status}) — skipping agent fallback`);
            (0, index_js_1.addBreadcrumb)('mcp', `Initialize rejected (HTTP ${initOutcome.status})`, {
                status: initOutcome.status,
            });
            return {
                ok: false,
                deterministic: true,
                reason: `http-${initOutcome.status}`,
            };
        }
        sessionId = initOutcome.sessionId;
    }
    catch (err) {
        (0, debug_js_1.logToFile)(`[MCP] initialize failed: ${err instanceof Error ? err.message : String(err)}`);
        return { ok: false, deterministic: false, reason: 'init-error' };
    }
    if (!sessionId) {
        return { ok: false, deterministic: false, reason: 'no-session-id' };
    }
    try {
        // Fire-and-forget — body is cancelled inside the timeout window.
        await fetchWithTimeout(mcpUrl, {
            method: 'POST',
            headers: { ...headers, 'Mcp-Session-Id': sessionId },
            body: JSON.stringify({
                jsonrpc: '2.0',
                method: 'notifications/initialized',
            }),
        }, MCP_NOTIFY_TIMEOUT_MS, signal, async (res) => {
            await res.body?.cancel().catch(() => undefined);
        });
    }
    catch {
        (0, debug_js_1.logToFile)('[MCP] notifications/initialized failed (continuing)');
    }
    const callTool = async (id, name, args) => (0, index_js_1.withWizardSpan)(`mcp.tool.${name}`, 'mcp.tool_call', { 'mcp.tool': name, 'mcp.session_id': sessionId }, async () => {
        try {
            // Read the body inside the timeout window — a hung body read
            // (gateway buffers the headers but stalls on data) would
            // otherwise pin the call past `MCP_FETCH_TIMEOUT_MS`.
            const body = await fetchWithTimeout(mcpUrl, {
                method: 'POST',
                headers: {
                    ...headers,
                    'Mcp-Session-Id': sessionId,
                    Accept: 'application/json, text/event-stream',
                },
                body: JSON.stringify({
                    jsonrpc: '2.0',
                    id,
                    method: 'tools/call',
                    params: { name, arguments: args },
                }),
            }, MCP_FETCH_TIMEOUT_MS, signal, async (res) => res.text());
            const sseData = body.match(/^data: (.+)$/m)?.[1] ?? '';
            const rpc = JSON.parse(sseData);
            if (rpc.error) {
                (0, debug_js_1.logToFile)(`[MCP] ${name} rpc error: ${JSON.stringify(rpc.error)}`);
                (0, index_js_1.addBreadcrumb)('mcp', `Tool ${name} returned RPC error`, {
                    code: rpc.error.code,
                    message: rpc.error.message,
                });
                return null;
            }
            return rpc.result?.content?.[0]?.text ?? null;
        }
        catch (err) {
            (0, debug_js_1.logToFile)(`[MCP] ${name} error: ${err instanceof Error ? err.message : String(err)}`);
            (0, index_js_1.addBreadcrumb)('mcp', `Tool ${name} threw`, {
                error: err instanceof Error ? err.message : String(err),
            });
            return null;
        }
    });
    return { ok: true, callTool };
}
// ── Agent fallback ────────────────────────────────────────────────────────────
// Cap how long we'll burn before giving up on the agent fallback. Was
// 30s historically — that's long enough to make a stuck Amplitude MCP
// look like the wizard is hung. 12s still covers a slow-but-recovering
// upstream (typical recovery is under 5s) while bounding the worst case
// to a fraction of the original.
const AGENT_FALLBACK_TIMEOUT_MS = 12_000;
async function runAgentFallback(accessToken, mcpUrl, agentPrompt, timeoutMs, externalSignal, onToolUse) {
    return (0, index_js_1.withWizardSpan)('mcp.fallback.agent', 'mcp.fallback', { 'mcp.url': mcpUrl, timeout_ms: timeoutMs }, async () => runAgentFallbackInner(accessToken, mcpUrl, agentPrompt, timeoutMs, externalSignal, onToolUse));
}
async function runAgentFallbackInner(accessToken, mcpUrl, agentPrompt, timeoutMs, externalSignal, onToolUse) {
    const { query } = await getSDKModule();
    const cliPath = getClaudeCodeExecutablePath();
    // Combine the timeout with any external abort signal.
    // AbortSignal.any() requires Node 20.3+; we support 18.17+, so we wire it manually.
    const controller = new AbortController();
    if (externalSignal?.aborted) {
        controller.abort();
    }
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    const onExternalAbort = () => controller.abort();
    externalSignal?.addEventListener('abort', onExternalAbort, { once: true });
    const collectedText = [];
    try {
        const response = query({
            prompt: agentPrompt,
            options: {
                // Model tier: omit explicit `model` so the SDK/gateway picks the same
                // Sonnet-class default as a normal Claude Code run. This path only
                // drives Amplitude MCP writes (charts/dashboard); it must stay capable
                // enough for tool JSON — routing `--mode fast` from the main wizard
                // here would regress dashboard fallback quality.
                claudeCodePath: cliPath,
                permissionMode: 'bypassPermissions',
                allowedTools: [],
                mcpServers: {
                    amplitude: {
                        type: 'http',
                        url: mcpUrl,
                        headers: { Authorization: `Bearer ${accessToken}` },
                    },
                },
                abortSignal: controller.signal,
            },
        });
        for await (const rawMessage of response) {
            const parsed = (0, schemas_js_1.safeParseSDKMessage)(rawMessage);
            if (!parsed.ok)
                continue;
            const message = parsed.message;
            if (message.type === 'assistant') {
                const content = message.message?.content;
                if (Array.isArray(content)) {
                    for (const block of content) {
                        if (block.type === 'text' && typeof block.text === 'string') {
                            collectedText.push(block.text);
                        }
                        else if (onToolUse &&
                            block.type === 'tool_use' &&
                            typeof block.name === 'string') {
                            // Surface tool_use to the caller so the UI can move beyond
                            // a static spinner. Wrapped in try/catch — a hook that throws
                            // must not abort the fallback (it would leave the user with
                            // an even worse "Creating charts…" hang and no result).
                            try {
                                onToolUse({ name: block.name, input: block.input });
                            }
                            catch (hookErr) {
                                (0, debug_js_1.logToFile)(`[MCP-fallback] onToolUse hook threw: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
                            }
                        }
                    }
                }
            }
        }
    }
    catch (err) {
        (0, debug_js_1.logToFile)(`[MCP-fallback] agent error: ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        clearTimeout(timer);
        externalSignal?.removeEventListener('abort', onExternalAbort);
    }
    return collectedText.join('');
}
/**
 * Make an authenticated call to the Amplitude MCP server.
 *
 * 1. Opens an MCP session and calls `opts.direct(callTool)`.
 * 2. If the direct call returns `null` or throws, falls back to a minimal
 *    Claude agent with only the Amplitude MCP configured.
 * 3. Passes the agent's text output to `opts.parseAgent` and returns the result.
 *
 * Returns `null` if both paths fail.
 */
async function callAmplitudeMcp(opts) {
    const { accessToken, mcpUrl = exports.AMPLITUDE_MCP_URL, direct, agentPrompt, parseAgent, label = 'callAmplitudeMcp', agentTimeoutMs = AGENT_FALLBACK_TIMEOUT_MS, 
    // Default to the wizard-wide abort signal so callers automatically
    // pick up Ctrl+C / SIGINT cancellation. Pass `abortSignal: undefined`
    // explicitly only in tests that need un-aborted isolation.
    abortSignal = (0, wizard_abort_js_1.getWizardAbortSignal)(), onAgentToolUse, } = opts;
    return (0, index_js_1.withWizardSpan)(`mcp.call.${label}`, 'mcp.call', { 'mcp.label': label, 'mcp.url': mcpUrl }, async () => {
        (0, index_js_1.addBreadcrumb)('mcp', `callAmplitudeMcp:${label} starting`);
        const result = await callAmplitudeMcpInner(accessToken, mcpUrl, direct, agentPrompt, parseAgent, label, agentTimeoutMs, abortSignal, onAgentToolUse);
        (0, index_js_1.addBreadcrumb)('mcp', `callAmplitudeMcp:${label} done`, {
            ok: result !== null,
        });
        return result;
    });
}
async function callAmplitudeMcpInner(accessToken, mcpUrl, direct, agentPrompt, parseAgent, label, agentTimeoutMs, abortSignal, onAgentToolUse) {
    // ── Direct path ────────────────────────────────────────────────────────────
    let directResult = null;
    let useFallback = false;
    if (direct) {
        const session = await openMcpSession(accessToken, mcpUrl, abortSignal);
        if (session.ok) {
            try {
                directResult = await direct(session.callTool);
                if (directResult === null) {
                    (0, debug_js_1.logToFile)(`[${label}] direct returned null — trying agent fallback`);
                    useFallback = true;
                }
            }
            catch (err) {
                (0, debug_js_1.logToFile)(`[${label}] direct threw — trying agent fallback: ${err instanceof Error ? err.message : String(err)}`);
                useFallback = true;
            }
        }
        else if (session.deterministic) {
            // Agent fallback uses the same access token, so the same auth
            // failure would just repeat after a 12s subprocess spin-up.
            // Surface the failure now and let the caller decide.
            (0, debug_js_1.logToFile)(`[${label}] MCP session failed deterministically (${session.reason}) — skipping agent fallback`);
            (0, index_js_1.addBreadcrumb)('mcp', `${label}: skipping fallback (deterministic)`, {
                reason: session.reason,
            });
            return null;
        }
        else {
            (0, debug_js_1.logToFile)(`[${label}] MCP session failed (${session.reason}) — trying agent fallback`);
            useFallback = true;
        }
    }
    else {
        (0, debug_js_1.logToFile)(`[${label}] no direct path — using agent fallback`);
        useFallback = true;
    }
    if (!useFallback)
        return directResult;
    // ── Agent fallback ────────────────────────────────────────────────────────
    (0, debug_js_1.logToFile)(`[${label}] running agent fallback`);
    const agentText = await runAgentFallback(accessToken, mcpUrl, agentPrompt, agentTimeoutMs, abortSignal, onAgentToolUse);
    if (!agentText) {
        (0, debug_js_1.logToFile)(`[${label}] agent fallback produced no output`);
        return null;
    }
    const parsed = parseAgent(agentText);
    if (parsed === null) {
        (0, debug_js_1.logToFile)(`[${label}] parseAgent returned null`);
    }
    return parsed;
}
