/**
 * Shared OAuth fetch wrapper for Anthropic API requests.
 *
 * Extracted from `providers/anthropic.ts` so that any provider or proxy layer
 * that needs OAuth-authenticated Anthropic requests can reuse the same logic.
 *
 * Applies full "cloaking" to make requests indistinguishable from the
 * official Claude CLI / CLIProxyAPI, which is required for OAuth + tools
 * to work correctly.
 *
 * @module proxy/oauthFetch
 */
import { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX, } from "../auth/anthropicOAuth.js";
import { logger } from "../utils/logger.js";
import { randomBytes, randomUUID } from "crypto";
// Re-export constants for consumers that previously imported them alongside
// the function from `providers/anthropic.ts`.
export { CLAUDE_CLI_USER_AGENT, MCP_TOOL_PREFIX };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Cache fake user IDs per token prefix (1-hour TTL, max 1000 entries). */
const userIdCache = new Map();
const USER_ID_CACHE_TTL_MS = 3_600_000; // 1 hour
const USER_ID_CACHE_MAX_SIZE = 1_000;
/** Evict expired entries and enforce max size (LRU-approximation via insertion order). */
function evictUserIdCache() {
    const now = Date.now();
    for (const [key, entry] of userIdCache) {
        if (entry.expires <= now) {
            userIdCache.delete(key);
        }
    }
    // If still over capacity, remove oldest entries (Map preserves insertion order)
    if (userIdCache.size > USER_ID_CACHE_MAX_SIZE) {
        const excess = userIdCache.size - USER_ID_CACHE_MAX_SIZE;
        let removed = 0;
        for (const key of userIdCache.keys()) {
            if (removed >= excess) {
                break;
            }
            userIdCache.delete(key);
            removed++;
        }
    }
}
function getOrCreateUserId(tokenPrefix) {
    evictUserIdCache();
    const cached = userIdCache.get(tokenPrefix);
    if (cached && cached.expires > Date.now()) {
        return cached.id;
    }
    const hex = randomBytes(32).toString("hex");
    const id = `user_${hex}_account_${randomUUID()}_session_${randomUUID()}`;
    userIdCache.set(tokenPrefix, {
        id,
        expires: Date.now() + USER_ID_CACHE_TTL_MS,
    });
    return id;
}
/** Compute a short hex hash of a string using Web Crypto (SHA-256). */
async function shortSha256(data) {
    const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data));
    return Array.from(new Uint8Array(buf))
        .map((b) => b.toString(16).padStart(2, "0"))
        .join("")
        .substring(0, 5);
}
// ---------------------------------------------------------------------------
// Main factory
// ---------------------------------------------------------------------------
/**
 * Creates a custom fetch function for OAuth-authenticated requests.
 * This wrapper applies all required transformations for OAuth mode:
 * - Uses Authorization: Bearer header (NOT x-api-key)
 * - Adds OAuth-required beta headers
 * - Sets User-Agent to Claude CLI
 * - Adds ?beta=true query parameter to /v1/messages
 * - Injects billing header & agent block into system prompt
 * - Injects fake user ID into metadata
 * - Adds Stainless SDK headers for fingerprint matching
 * - Disables thinking when tool_choice is forced
 *
 * Accepts a getter function instead of a static token so that refreshed
 * tokens are picked up automatically on each request.
 *
 * @param getToken              - Function returning the current OAuth access token
 * @param includeOptionalBetas  - Whether to include optional beta headers (default true)
 * @param enableMcpPrefix       - Whether to apply mcp_ prefix/strip logic to tool names (default false)
 * @param skipBodyTransform     - When true, skip ALL body modifications (billing header, user ID, tool prefix).
 *                                Used for proxy passthrough where the request body must be forwarded as-is.
 */
export function createOAuthFetch(getToken, includeOptionalBetas = true, enableMcpPrefix = false, skipBodyTransform = false) {
    return async (input, init) => {
        // Build the URL
        let requestUrl = null;
        try {
            if (typeof input === "string" || input instanceof URL) {
                requestUrl = new URL(input.toString());
            }
            else if (input instanceof Request) {
                requestUrl = new URL(input.url);
            }
        }
        catch {
            requestUrl = null;
        }
        // Add ?beta=true to /v1/messages endpoint
        if (requestUrl &&
            requestUrl.pathname === "/v1/messages" &&
            !requestUrl.searchParams.has("beta")) {
            requestUrl.searchParams.set("beta", "true");
        }
        // Build new headers
        const requestHeaders = new Headers();
        // Copy headers from Request object if present
        if (input instanceof Request) {
            input.headers.forEach((value, key) => {
                requestHeaders.set(key, value);
            });
        }
        // Copy headers from init if present
        if (init?.headers) {
            if (init.headers instanceof Headers) {
                init.headers.forEach((value, key) => {
                    requestHeaders.set(key, value);
                });
            }
            else if (Array.isArray(init.headers)) {
                for (const [key, value] of init.headers) {
                    if (typeof value !== "undefined") {
                        requestHeaders.set(key, String(value));
                    }
                }
            }
            else {
                for (const [key, value] of Object.entries(init.headers)) {
                    if (typeof value !== "undefined") {
                        requestHeaders.set(key, String(value));
                    }
                }
            }
        }
        // ------------------------------------------------------------------
        // Beta headers — preserve existing client betas and merge in required ones.
        // In passthrough mode, Claude Code sends its own betas that MUST be kept
        // (e.g., context-management-2025-06-27). We only add oauth if missing.
        // ------------------------------------------------------------------
        const existingBetas = (requestHeaders.get("anthropic-beta") ?? "")
            .split(",")
            .map((s) => s.trim())
            .filter(Boolean);
        const requiredBetas = ["oauth-2025-04-20"];
        // Only add our betas if not already present in client's headers
        if (!skipBodyTransform) {
            // Direct NeuroLink usage — set full beta list
            requiredBetas.push("claude-code-20250219", ...(includeOptionalBetas ? ["interleaved-thinking-2025-05-14"] : []), "prompt-caching-scope-2026-01-05");
        }
        const allBetas = [...new Set([...existingBetas, ...requiredBetas])];
        const mergedBetas = allBetas.join(",");
        // Set OAuth authorization (Bearer token, NOT x-api-key)
        // Call getToken() each time so refreshed tokens are used automatically.
        requestHeaders.set("authorization", `Bearer ${getToken()}`);
        requestHeaders.set("anthropic-beta", mergedBetas);
        if (!skipBodyTransform) {
            // Only override user-agent for direct NeuroLink usage
            requestHeaders.set("user-agent", CLAUDE_CLI_USER_AGENT);
        }
        requestHeaders.delete("x-api-key");
        // Identity / fingerprint headers (skip in passthrough — client sends its own)
        if (!skipBodyTransform) {
            requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");
            requestHeaders.set("x-app", "cli");
            requestHeaders.set("connection", "keep-alive");
            // Stainless SDK headers
            requestHeaders.set("x-stainless-retry-count", "0");
            requestHeaders.set("x-stainless-runtime-version", "v24.3.0");
            requestHeaders.set("x-stainless-package-version", "0.74.0");
            requestHeaders.set("x-stainless-runtime", "node");
            requestHeaders.set("x-stainless-lang", "js");
            requestHeaders.set("x-stainless-arch", process.arch === "x64" ? "x64" : process.arch);
            requestHeaders.set("x-stainless-os", process.platform === "darwin"
                ? "MacOS"
                : process.platform === "win32"
                    ? "Windows"
                    : "Linux");
            requestHeaders.set("x-stainless-timeout", "600");
        }
        logger.debug("[createOAuthFetch] Making OAuth request:", {
            url: requestUrl?.toString() || input.toString(),
            hasAuthorization: requestHeaders.has("authorization"),
            authType: "Bearer",
            anthropicBeta: requestHeaders.get("anthropic-beta"),
            userAgent: requestHeaders.get("user-agent"),
        });
        // ------------------------------------------------------------------
        // Body transformations (skipped in passthrough/proxy mode)
        // ------------------------------------------------------------------
        const sourceRequest = input instanceof Request ? input : undefined;
        const method = init?.method ?? sourceRequest?.method;
        let body = init?.body;
        if (body === undefined &&
            sourceRequest &&
            method !== "GET" &&
            method !== "HEAD") {
            // Read the body as text (not ReadableStream) so that the JSON transforms
            // below can parse and modify it. A ReadableStream would bypass the
            // `typeof body === "string"` branch and skip all cloaking transforms.
            const contentType = sourceRequest.headers.get("content-type") ?? "";
            if (contentType.includes("application/json")) {
                body = (await sourceRequest.clone().text()) || undefined;
            }
            else {
                body = sourceRequest.clone().body ?? undefined;
            }
        }
        if (body && typeof body === "string" && !skipBodyTransform) {
            try {
                const parsed = JSON.parse(body);
                // --- mcp_ prefix (only when explicitly enabled) ----------------
                if (enableMcpPrefix) {
                    if (parsed.tools && Array.isArray(parsed.tools)) {
                        parsed.tools = parsed.tools.map((tool) => ({
                            ...tool,
                            name: tool.name ? `${MCP_TOOL_PREFIX}${tool.name}` : tool.name,
                        }));
                    }
                    if (parsed.messages && Array.isArray(parsed.messages)) {
                        parsed.messages = parsed.messages.map((msg) => {
                            if (msg.content && Array.isArray(msg.content)) {
                                msg.content = msg.content.map((block) => {
                                    const b = block;
                                    if (b.type === "tool_use" && b.name) {
                                        return {
                                            ...b,
                                            name: `${MCP_TOOL_PREFIX}${b.name}`,
                                        };
                                    }
                                    return block;
                                });
                            }
                            return msg;
                        });
                    }
                }
                // --- Disable thinking when tool_choice is forced ---------------
                if (parsed.tool_choice?.type === "any" ||
                    parsed.tool_choice?.type === "tool") {
                    delete parsed.thinking;
                }
                // --- Inject billing header + agent block into system prompt ----
                const version = "2.1.63";
                const randomHex = Array.from(crypto.getRandomValues(new Uint8Array(2)))
                    .map((b) => b.toString(16).padStart(2, "0"))
                    .join("")
                    .substring(0, 3);
                const bodyString = JSON.stringify(parsed);
                const cch = await shortSha256(bodyString);
                const billingBlock = {
                    type: "text",
                    text: `x-anthropic-billing-header: cc_version=${version}.${randomHex}; cc_entrypoint=cli; cch=${cch};`,
                };
                const agentBlock = {
                    type: "text",
                    text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
                };
                // Normalise `system` to an array and prepend billing + agent blocks
                if (parsed.system) {
                    if (typeof parsed.system === "string") {
                        parsed.system = [{ type: "text", text: parsed.system }];
                    }
                    if (Array.isArray(parsed.system)) {
                        parsed.system = [billingBlock, agentBlock, ...parsed.system];
                    }
                }
                else {
                    parsed.system = [billingBlock, agentBlock];
                }
                // --- Inject fake user ID into metadata -------------------------
                const token = getToken();
                const tokenPrefix = token.substring(0, Math.min(20, token.length));
                parsed.metadata = {
                    ...parsed.metadata,
                    user_id: getOrCreateUserId(tokenPrefix),
                };
                body = JSON.stringify(parsed);
            }
            catch {
                // Ignore JSON parse errors — pass body through unchanged
            }
        }
        // Remove any inherited content-length — the body may have been transformed
        // above, so the original length is stale. Let fetch/undici recalculate it.
        requestHeaders.delete("content-length");
        // Make the request
        const response = await fetch(requestUrl?.toString() ||
            (input instanceof Request ? input.url : input.toString()), {
            ...init,
            method,
            body,
            signal: init?.signal ?? sourceRequest?.signal,
            headers: requestHeaders,
        });
        // Transform streaming response to rename tools back (remove mcp_ prefix).
        // Uses a dynamically-sized carry buffer that holds any incomplete JSON
        // token spanning a chunk boundary (e.g. a partial `"name": "mcp_..."`).
        if (enableMcpPrefix && response.body) {
            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            const encoder = new TextEncoder();
            const responseHeaders = new Headers(response.headers);
            responseHeaders.delete("content-length");
            let carry = "";
            const stream = new ReadableStream({
                async pull(controller) {
                    const { done, value } = await reader.read();
                    if (done) {
                        // Flush any remaining carry
                        if (carry) {
                            const flushed = carry.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
                            controller.enqueue(encoder.encode(flushed));
                            carry = "";
                        }
                        controller.close();
                        return;
                    }
                    const chunkText = decoder.decode(value, { stream: true });
                    const combined = carry + chunkText;
                    // Detect a trailing partial `"name":\s*"mcp_...` that hasn't closed
                    // yet (no closing quote for the value). We must keep the entire
                    // partial field in carry so the regex can match it once the next
                    // chunk completes it.
                    const partialMatch = combined.match(/"name"\s*:\s*"mcp_[^"]*$/);
                    let safeText;
                    if (partialMatch && partialMatch.index !== undefined) {
                        // Partial mcp_ name field at end — carry the entire partial field
                        safeText = combined.slice(0, partialMatch.index);
                        carry = combined.slice(partialMatch.index);
                    }
                    else {
                        // No partial mcp_ field — safe to process everything.
                        // Still carry trailing content after the last quote to avoid
                        // splitting other JSON tokens.
                        const lastQuote = combined.lastIndexOf('"');
                        const safeLen = lastQuote >= 0 ? lastQuote + 1 : combined.length;
                        safeText = combined.slice(0, safeLen);
                        carry = combined.slice(safeLen);
                    }
                    // Apply the mcp_ stripping regex on the safe portion
                    const replaced = safeText.replace(/"name"\s*:\s*"mcp_([^"]+)"/g, '"name": "$1"');
                    if (replaced) {
                        controller.enqueue(encoder.encode(replaced));
                    }
                },
                async cancel(reason) {
                    await reader.cancel(reason);
                },
            });
            return new Response(stream, {
                status: response.status,
                statusText: response.statusText,
                headers: responseHeaders,
            });
        }
        return response;
    };
}
//# sourceMappingURL=oauthFetch.js.map