import { getLogger } from '../logging/logger.js';
/**
 * Converts Headers to a plain object for logging.
 */
function headersToObject(headers) {
    const result = {};
    headers.forEach((value, key) => {
        result[key] = value;
    });
    return result;
}
// Track which requests have been retried to prevent infinite loops
const retriedRequests = new WeakSet();
// Store cloned request bodies for potential retry (body can only be read once)
const requestBodies = new WeakMap();
/**
 * Creates authentication middleware for openapi-fetch.
 *
 * This middleware intercepts requests and adds OAuth authentication headers
 * using the provided AuthStrategy. It also handles 401 responses by invalidating
 * the token and retrying the request once with a fresh token.
 *
 * @param auth - The authentication strategy to use
 * @returns Middleware that adds auth headers to requests and retries on 401
 */
export function createAuthMiddleware(auth) {
    const logger = getLogger();
    let hasHadSuccess = false;
    return {
        async onRequest({ request }) {
            if (auth.getAuthorizationHeader) {
                const authHeader = await auth.getAuthorizationHeader();
                request.headers.set('Authorization', authHeader);
            }
            // Clone the request body before it gets consumed, so we can retry if needed
            if (request.body && auth.invalidateToken && auth.getAuthorizationHeader) {
                const clonedRequest = request.clone();
                const bodyBuffer = await clonedRequest.arrayBuffer();
                requestBodies.set(request, bodyBuffer);
            }
            return request;
        },
        async onResponse({ request, response }) {
            if (response.status !== 401) {
                hasHadSuccess = true;
            }
            // Only retry on 401 if we have had a prior successful response (indicating
            // token expiry rather than bad credentials), haven't already retried this
            // request, and the strategy supports token invalidation
            if (response.status === 401 &&
                hasHadSuccess &&
                !retriedRequests.has(request) &&
                auth.invalidateToken &&
                auth.getAuthorizationHeader) {
                logger.debug('[AuthMiddleware] Received 401, invalidating token and retrying');
                // Mark this request as retried to prevent infinite loops
                retriedRequests.add(request);
                // Invalidate the cached token
                auth.invalidateToken();
                // Get a fresh token
                const newAuthHeader = await auth.getAuthorizationHeader();
                // Rebuild the request with the new auth header
                const newHeaders = new Headers(request.headers);
                newHeaders.set('Authorization', newAuthHeader);
                // Get the saved body (if any)
                const savedBody = requestBodies.get(request);
                // Create a new request with the fresh token
                const retryRequest = new Request(request.url, {
                    method: request.method,
                    headers: newHeaders,
                    body: savedBody,
                    // TypeScript doesn't know about duplex, but it's needed for streaming bodies
                    ...(savedBody ? { duplex: 'half' } : {}),
                });
                // Retry the request
                const retryResponse = await fetch(retryRequest);
                logger.debug({ status: retryResponse.status }, `[AuthMiddleware] Retry response: ${retryResponse.status}`);
                return retryResponse;
            }
            return response;
        },
    };
}
const DEFAULT_RATE_LIMIT_MAX_RETRIES = 3;
const DEFAULT_RATE_LIMIT_BASE_DELAY_MS = 1000;
const DEFAULT_RATE_LIMIT_MAX_DELAY_MS = 30000;
const DEFAULT_RATE_LIMIT_STATUS_CODES = [429];
const DEFAULT_RATE_LIMIT_JITTER_RATIO = 0.2;
async function sleepWithAbort(ms, signal) {
    if (ms <= 0) {
        return true;
    }
    if (signal?.aborted) {
        return false;
    }
    await new Promise((resolve) => {
        function onAbort() {
            clearTimeout(timeout);
            signal?.removeEventListener('abort', onAbort);
            resolve();
        }
        const timeout = setTimeout(() => {
            signal?.removeEventListener('abort', onAbort);
            resolve();
        }, ms);
        signal?.addEventListener('abort', onAbort);
    });
    return !signal?.aborted;
}
/**
 * Parses the Retry-After header into a delay in milliseconds.
 * Supports both seconds and HTTP date formats. Returns undefined if
 * the header is missing or invalid.
 */
function parseRetryAfter(headerValue) {
    if (!headerValue) {
        return undefined;
    }
    const seconds = Number(headerValue);
    if (!Number.isNaN(seconds)) {
        return Math.max(0, Math.round(seconds * 1000));
    }
    const dateMs = Date.parse(headerValue);
    if (!Number.isNaN(dateMs)) {
        const diff = dateMs - Date.now();
        return diff > 0 ? diff : 0;
    }
    return undefined;
}
/**
 * Returns the next backoff delay based on attempt count.
 */
function computeBackoffDelayMs(attempt, baseDelayMs, maxDelayMs) {
    const delay = baseDelayMs * Math.pow(2, Math.max(0, attempt));
    if (delay <= 0) {
        return 0;
    }
    const jitter = Math.round(delay * DEFAULT_RATE_LIMIT_JITTER_RATIO * Math.random());
    return Math.min(delay + jitter, maxDelayMs);
}
/**
 * Creates rate limiting middleware for openapi-fetch clients.
 *
 * This middleware inspects responses for rate-limit status codes (by default
 * 429 Too Many Requests), uses the Retry-After header when present to
 * determine a delay, and retries the request up to a configurable limit.
 *
 * The middleware is generic and can be used by MRT and other clients. It does
 * not currently read CLI configuration directly; callers should pass
 * configuration via the factory function.
 */
export function createRateLimitMiddleware(config = {}) {
    const logger = getLogger();
    const { maxRetries = DEFAULT_RATE_LIMIT_MAX_RETRIES, baseDelayMs = DEFAULT_RATE_LIMIT_BASE_DELAY_MS, maxDelayMs = DEFAULT_RATE_LIMIT_MAX_DELAY_MS, statusCodes = DEFAULT_RATE_LIMIT_STATUS_CODES, prefix, fetch: configFetch, } = config;
    const tag = prefix ? `[${prefix} RATE]` : '[RATE]';
    return {
        async onResponse(ctx) {
            const { request, response } = ctx;
            const ctxFetch = ctx.fetch;
            // Only handle configured status codes
            if (!statusCodes.includes(response.status) || maxRetries <= 0) {
                return response;
            }
            const fetchFn = ctxFetch ?? configFetch ?? (typeof fetch === 'function' ? fetch : undefined);
            if (!fetchFn) {
                return response;
            }
            const reqWithAttempt = request;
            const startingAttempt = reqWithAttempt._rateLimitAttempt ?? 0;
            // If openapi-fetch provides ctx.fetch, it typically re-enters the middleware chain.
            // In that case, do a single retry and let subsequent attempts be handled by
            // subsequent middleware invocations (guarded by _rateLimitAttempt).
            if (ctxFetch) {
                if (startingAttempt >= maxRetries) {
                    logger.debug({ status: response.status, attempt: startingAttempt, maxRetries }, `${tag} Max retries reached, not retrying request`);
                    return response;
                }
                const retryAfterHeader = response.headers.get('Retry-After');
                let delayMs = parseRetryAfter(retryAfterHeader);
                if (delayMs === undefined) {
                    delayMs = computeBackoffDelayMs(startingAttempt, baseDelayMs, maxDelayMs);
                }
                logger.warn({
                    status: response.status,
                    attempt: startingAttempt + 1,
                    maxRetries,
                    delayMs,
                    retryAfter: retryAfterHeader ?? undefined,
                    url: request.url,
                }, `${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${startingAttempt + 1}/${maxRetries})`);
                const canRetry = await sleepWithAbort(delayMs, request.signal);
                if (!canRetry) {
                    return response;
                }
                reqWithAttempt._rateLimitAttempt = startingAttempt + 1;
                let retryRequest = request;
                try {
                    retryRequest = request.clone();
                }
                catch {
                    logger.debug({ url: request.url }, `${tag} Could not clone request for retry; retrying with original request`);
                }
                return fetchFn(retryRequest);
            }
            // Fallback path: if ctx.fetch is not provided, handle retries in this invocation.
            let lastResponse = response;
            let attempt = startingAttempt;
            while (statusCodes.includes(lastResponse.status) && attempt < maxRetries) {
                const retryAfterHeader = lastResponse.headers.get('Retry-After');
                let delayMs = parseRetryAfter(retryAfterHeader);
                if (delayMs === undefined) {
                    delayMs = computeBackoffDelayMs(attempt, baseDelayMs, maxDelayMs);
                }
                logger.warn({
                    status: lastResponse.status,
                    attempt: attempt + 1,
                    maxRetries,
                    delayMs,
                    retryAfter: retryAfterHeader ?? undefined,
                    url: request.url,
                }, `${tag} Rate limit encountered, retrying request after ${delayMs}ms (attempt ${attempt + 1}/${maxRetries})`);
                const canRetry = await sleepWithAbort(delayMs, request.signal);
                if (!canRetry) {
                    return lastResponse;
                }
                attempt += 1;
                reqWithAttempt._rateLimitAttempt = attempt;
                let retryRequest = request;
                try {
                    retryRequest = request.clone();
                }
                catch {
                    logger.debug({ url: request.url }, `${tag} Could not clone request for retry; retrying with original request`);
                }
                lastResponse = await fetchFn(retryRequest);
            }
            if (statusCodes.includes(lastResponse.status) && attempt >= maxRetries) {
                logger.debug({ status: lastResponse.status, attempt, maxRetries }, `${tag} Max retries reached, not retrying request`);
            }
            return lastResponse;
        },
    };
}
/**
 * Masks specified keys in an object for logging.
 * Only masks top-level keys, replaces values with '...' placeholder.
 */
function maskBody(body, keysToMask) {
    if (!keysToMask || keysToMask.length === 0 || typeof body !== 'object' || body === null) {
        return body;
    }
    const masked = { ...body };
    for (const key of keysToMask) {
        if (key in masked) {
            masked[key] = '...';
        }
    }
    return masked;
}
/**
 * Creates logging middleware for openapi-fetch clients.
 *
 * Logs request/response details at debug and trace levels.
 *
 * @param config - Logging configuration or prefix string for backwards compatibility
 * @returns Middleware that logs requests and responses
 *
 * @example
 * // Simple usage with just a prefix
 * client.use(createLoggingMiddleware('OCAPI'));
 *
 * @example
 * // With body masking for large payloads
 * client.use(createLoggingMiddleware({
 *   prefix: 'MRT',
 *   maskBodyKeys: ['data']  // Masks base64-encoded bundle data
 * }));
 */
export function createLoggingMiddleware(config) {
    // Support both string (prefix) and config object for backwards compatibility
    const { prefix, maskBodyKeys } = typeof config === 'string' ? { prefix: config, maskBodyKeys: undefined } : (config ?? {});
    const reqTag = prefix ? `[${prefix} REQ]` : '';
    const respTag = prefix ? `[${prefix} RESP]` : '';
    return {
        async onRequest({ request }) {
            const logger = getLogger();
            const url = request.url;
            logger.debug({ method: request.method, url }, `${reqTag} ${request.method} ${url}`);
            // Read body from the request (already serialized by openapi-fetch)
            let body;
            if (request.body) {
                const clonedRequest = request.clone();
                const text = await clonedRequest.text();
                try {
                    body = JSON.parse(text);
                }
                catch {
                    body = text;
                }
            }
            // Mask sensitive/large body keys before logging
            const maskedBody = maskBody(body, maskBodyKeys);
            logger.trace({ method: request.method, url, headers: headersToObject(request.headers), body: maskedBody }, `${reqTag} ${request.method} ${url} body`);
            request._startTime = Date.now();
            return request;
        },
        async onResponse({ request, response }) {
            const logger = getLogger();
            const startTime = request._startTime ?? Date.now();
            const duration = Date.now() - startTime;
            const url = request.url;
            logger.debug({ method: request.method, url, status: response.status, duration }, `${respTag} ${request.method} ${url} ${response.status} ${duration}ms`);
            const clonedResponse = response.clone();
            let responseBody;
            // Read as text first, then try to parse as JSON.
            // This avoids a bug where json() consumes the body stream even when parsing fails,
            // making subsequent text() calls fail with "Body has already been read".
            const text = await clonedResponse.text();
            try {
                responseBody = JSON.parse(text);
            }
            catch {
                responseBody = text;
            }
            // Mask sensitive/large body keys before logging
            const maskedResponseBody = maskBody(responseBody, maskBodyKeys);
            logger.trace({ method: request.method, url, headers: headersToObject(response.headers), body: maskedResponseBody }, `${respTag} ${request.method} ${url} body`);
            return response;
        },
    };
}
/**
 * Creates middleware that sets the User-Agent header on requests.
 *
 * Sets both the standard `User-Agent` header and a custom `sfdc_user_agent` header
 * with the same value.
 *
 * @param config - Configuration with the User-Agent string
 * @returns Middleware that sets the User-Agent headers
 *
 * @example
 * ```typescript
 * const client = createOcapiClient(config, auth);
 * client.use(createUserAgentMiddleware({ userAgent: 'b2c-cli/0.1.0' }));
 * ```
 */
export function createUserAgentMiddleware(config) {
    return {
        async onRequest({ request }) {
            request.headers.set('User-Agent', config.userAgent);
            request.headers.set('sfdc_user_agent', config.userAgent);
            return request;
        },
    };
}
export function createExtraParamsMiddleware(config) {
    const logger = getLogger();
    return {
        async onRequest({ request }) {
            let modifiedRequest = request;
            // HTTP methods that don't allow a request body
            const methodsWithoutBody = ['GET', 'HEAD'];
            const canHaveBody = !methodsWithoutBody.includes(modifiedRequest.method.toUpperCase());
            // Add extra headers first (before other modifications)
            if (config.headers && Object.keys(config.headers).length > 0) {
                const newHeaders = new Headers(modifiedRequest.headers);
                for (const [key, value] of Object.entries(config.headers)) {
                    newHeaders.set(key, value);
                }
                logger.trace({ extraHeaders: config.headers }, '[ExtraParams] Adding extra headers to request');
                modifiedRequest = new Request(modifiedRequest.url, {
                    method: modifiedRequest.method,
                    headers: newHeaders,
                    ...(canHaveBody && modifiedRequest.body ? { body: modifiedRequest.body, duplex: 'half' } : {}),
                });
            }
            // Add extra query parameters
            if (config.query && Object.keys(config.query).length > 0) {
                const url = new URL(modifiedRequest.url);
                for (const [key, value] of Object.entries(config.query)) {
                    if (value !== undefined) {
                        url.searchParams.set(key, String(value));
                    }
                }
                logger.trace({ extraQuery: config.query, originalUrl: modifiedRequest.url, newUrl: url.toString() }, '[ExtraParams] Adding extra query params to URL');
                modifiedRequest = new Request(url.toString(), {
                    method: modifiedRequest.method,
                    headers: modifiedRequest.headers,
                    ...(canHaveBody && modifiedRequest.body ? { body: modifiedRequest.body, duplex: 'half' } : {}),
                });
            }
            // Merge extra body fields for JSON requests
            if (config.body && Object.keys(config.body).length > 0) {
                const contentType = modifiedRequest.headers.get('content-type');
                if (contentType?.includes('application/json') && modifiedRequest.body) {
                    const clonedRequest = modifiedRequest.clone();
                    const originalBody = await clonedRequest.text();
                    try {
                        const parsedBody = JSON.parse(originalBody);
                        const mergedBody = { ...parsedBody, ...config.body };
                        logger.trace({ originalBody: parsedBody, extraBody: config.body, mergedBody }, '[ExtraParams] Merging extra body fields into request');
                        modifiedRequest = new Request(modifiedRequest.url, {
                            method: modifiedRequest.method,
                            headers: modifiedRequest.headers,
                            body: JSON.stringify(mergedBody),
                        });
                    }
                    catch {
                        logger.warn('[ExtraParams] Could not parse request body as JSON, skipping body merge');
                    }
                }
                else if (!modifiedRequest.body && canHaveBody) {
                    // No existing body, create one with extra fields (only for methods that allow a body)
                    logger.trace({ body: config.body }, '[ExtraParams] Creating new body with extra fields');
                    const headers = new Headers(modifiedRequest.headers);
                    headers.set('content-type', 'application/json');
                    modifiedRequest = new Request(modifiedRequest.url, {
                        method: modifiedRequest.method,
                        headers,
                        body: JSON.stringify(config.body),
                    });
                }
            }
            return modifiedRequest;
        },
    };
}
//# sourceMappingURL=middleware.js.map