/**
 * API Client Implementation
 *
 * This module contains the core API client implementation, including
 * HTTP method handlers, response processing, and client factory functions.
 */
import { getAuthorizationHeader } from "./auth";
import { createSemaphore } from "./concurrency";
import { createDebugLogger, createDebugFetch } from "./debug";
import { pollUntilComplete } from "./polling";
import { resolveAuthToken, invalidateCredentialsToken, isCliLoginAvailable, } from "../auth";
import { getZapierBaseUrl, isLocalhostBaseUrl } from "../utils/url-utils";
import { sleep, calculateExponentialBackoffMs } from "../utils/retry-utils";
import { isPlainObject } from "../utils/type-guard-utils";
import { ZapierApiError, ZapierApprovalError, ZapierAuthenticationError, ZapierConfigurationError, ZapierTimeoutError, ZapierValidationError, ZapierResourceNotFoundError, ZapierRateLimitError, } from "../types/errors";
import { MAX_CONCURRENCY_LIMIT, ZAPIER_MAX_CONCURRENT_REQUESTS, ZAPIER_MAX_NETWORK_RETRIES, ZAPIER_MAX_NETWORK_RETRY_DELAY_MS, getZapierApprovalMode, getZapierDefaultApprovalMode, getZapierSdkService, DEFAULT_APPROVAL_TIMEOUT_MS, DEFAULT_MAX_APPROVAL_RETRIES, } from "../constants";
import { SDK_VERSION } from "../sdk-version";
import { openApproval } from "../utils/open-approval";
import { z } from "zod";
const ApprovalStatusSchema = z.enum(["pending_approval", "approved", "denied"]);
const CreateApprovalResponseSchema = z.object({
    status: ApprovalStatusSchema,
    approval_id: z.string(),
    approval_url: z.string().url(),
    poll_url: z.string().url(),
});
const PollApprovalResponseSchema = z.object({
    status: ApprovalStatusSchema,
    approval_id: z.string(),
});
/**
 * Parse rate limit information from response headers
 */
function parseRateLimitHeaders(response) {
    const info = {};
    // Parse Retry-After header (can be seconds or HTTP date)
    const retryAfter = response.headers.get("retry-after");
    if (retryAfter) {
        const seconds = parseInt(retryAfter, 10);
        if (!isNaN(seconds)) {
            info.retryAfterMs = seconds * 1000;
        }
        else {
            // Try parsing as HTTP date
            const date = Date.parse(retryAfter);
            if (!isNaN(date)) {
                info.retryAfterMs = Math.max(0, date - Date.now());
            }
        }
    }
    // Parse X-RateLimit-Reset (Unix timestamp)
    const reset = response.headers.get("x-ratelimit-reset");
    if (reset) {
        const resetTimestamp = parseInt(reset, 10);
        if (!isNaN(resetTimestamp)) {
            // Convert to ms (timestamps are usually in seconds)
            info.resetMs = resetTimestamp * 1000;
            // If no Retry-After, calculate wait time from reset
            if (info.retryAfterMs === undefined) {
                info.retryAfterMs = Math.max(0, info.resetMs - Date.now());
            }
        }
    }
    // Parse X-RateLimit-Limit
    const limit = response.headers.get("x-ratelimit-limit");
    if (limit) {
        const limitNum = parseInt(limit, 10);
        if (!isNaN(limitNum)) {
            info.limit = limitNum;
        }
    }
    // Parse X-RateLimit-Remaining
    const remaining = response.headers.get("x-ratelimit-remaining");
    if (remaining) {
        const remainingNum = parseInt(remaining, 10);
        if (!isNaN(remainingNum)) {
            info.remaining = remainingNum;
        }
    }
    return info;
}
// Configuration for paths
const pathConfig = {
    // e.g. /relay -> https://sdkapi.zapier.com/api/v0/sdk/relay/...
    "/relay": {
        authHeader: "X-Relay-Authorization",
        pathPrefix: "/api/v0/sdk/relay",
    },
    // e.g. /zapier -> https://sdkapi.zapier.com/api/v0/sdk/zapier/...
    "/zapier": {
        authHeader: "Authorization",
        pathPrefix: "/api/v0/sdk/zapier",
    },
    // e.g. /tables -> https://sdkapi.zapier.com/api/v0/sdk/tables/...
    "/tables": {
        authHeader: "Authorization",
        pathPrefix: "/api/v0/sdk/tables",
    },
    // e.g. /trigger-inbox -> https://sdkapi.zapier.com/api/v0/sdk/trigger-inbox/...
    "/trigger-inbox": {
        authHeader: "Authorization",
        pathPrefix: "/api/v0/sdk/trigger-inbox",
    },
};
class ZapierApiClient {
    constructor(options) {
        this.options = options;
        /**
         * Perform a request against an already-resolved URL.
         *
         * Does auth, header merging, and 429 retry — all the cross-cutting
         * concerns that every Zapier-bound HTTP call needs. Callers that have a
         * path (e.g. `/relay/...`) should use `rawFetch` instead, which does
         * path → URL resolution and delegates here.
         *
         * Exposed as a separate helper so call sites with a server-supplied
         * absolute URL (e.g. an approval poll URL) can still share the same
         * auth/retry pipeline instead of reaching for `this.options.fetch`
         * directly and drifting.
         */
        this.rawFetchUrl = async (url, init, pathConfig) => {
            if (init?.body && (isPlainObject(init.body) || Array.isArray(init.body))) {
                init.body = JSON.stringify(init.body);
            }
            const builtHeaders = await this.buildHeaders(init, pathConfig);
            const inputHeaders = new Headers(init?.headers ?? {});
            const mergedHeaders = new Headers();
            builtHeaders.forEach((value, key) => {
                mergedHeaders.set(key, value);
            });
            inputHeaders.forEach((value, key) => {
                mergedHeaders.set(key, value);
            });
            this.applyTelemetryHeaders(mergedHeaders);
            let retries = 0;
            // Retry loop for rate limiting (429)
            while (true) {
                const response = await this.options.fetch(url, {
                    ...init,
                    headers: mergedHeaders,
                });
                if (response.status !== 429) {
                    return response;
                }
                // Calculate delay: prefer Retry-After/X-RateLimit-Reset, fall back to exponential backoff
                const rateLimitInfo = parseRateLimitHeaders(response);
                const delayMs = rateLimitInfo.retryAfterMs ??
                    calculateExponentialBackoffMs(retries + 1);
                // If delay exceeds maxNetworkRetryDelayMs or retries exhausted, throw immediately
                if (delayMs > this.maxNetworkRetryDelayMs ||
                    retries >= this.maxNetworkRetries) {
                    throw new ZapierRateLimitError("Rate limited", {
                        statusCode: 429,
                        rateLimit: rateLimitInfo,
                        retries,
                    });
                }
                retries++;
                // Emit retry event
                this.emitEvent("api:rate_limit_retry", {
                    retry: retries,
                    maxNetworkRetries: this.maxNetworkRetries,
                    delayMs,
                    path: url,
                    method: init?.method ?? "GET",
                    rateLimit: rateLimitInfo,
                });
                // Pass the caller's signal into sleep so an abort during the
                // backoff stops the wait — the next fetch in the loop then
                // surfaces the abort, and `withSemaphore`'s finally releases the
                // permit. Without this, an aborted request keeps holding its
                // slot for the full Retry-After interval and blocks every queued
                // waiter behind it.
                await sleep(delayMs, init?.signal ?? undefined);
            }
        };
        /**
         * Wrap an outbound HTTP call with the concurrency semaphore. Used by both
         * `rawFetch` (path-based) and the approval-poll path (absolute URL); each
         * caller acquires per-attempt, so 429 retry sleep is held but the gap
         * between approval polls and the human-approval wait are not.
         *
         * The release is registered in a finally that wraps the entire post-
         * acquire flow — including the `wait_end` event emission — so a throwing
         * `onEvent` handler can never leak a permit.
         *
         * Slot lifetime is intentionally tied to "fetch resolves" (headers
         * received), NOT to "response body fully consumed". WHATWG `fetch()`
         * resolves once headers are in; the body is still streaming. We rely on
         * that boundary so streaming responses (SSE, long-running chunked reads)
         * don't pin a permit for the lifetime of the stream — a single SSE
         * consumer would otherwise hold one of N slots for as long as the
         * connection stays open. Do not move the release into a path that awaits
         * body consumption (e.g. `parseResult` / `response.text()`); doing so
         * would silently break streaming consumers without failing any of the
         * short-request tests.
         */
        this.withSemaphore = async (context, fn) => {
            const fastRelease = this.semaphore.tryAcquire();
            let waitStart = null;
            let release = fastRelease;
            if (release === null) {
                waitStart = Date.now();
                this.emitEvent("api:concurrency_wait_start", {
                    url: context.url,
                    method: context.method,
                });
                release = await this.semaphore.acquire(context.signal ?? undefined);
            }
            const acquiredRelease = release;
            try {
                if (waitStart !== null) {
                    this.emitEvent("api:concurrency_wait_end", {
                        url: context.url,
                        method: context.method,
                        waitedMs: Date.now() - waitStart,
                    });
                }
                return await fn();
            }
            finally {
                acquiredRelease();
            }
        };
        /**
         * Perform a request with auth, header merging, and rate-limit (429) retries.
         * Does NOT handle 403 approval_required — that's routed by `fetch`.
         *
         * Concurrency: a semaphore slot is held across the entire call, including
         * the 429 retry sleep inside `rawFetchUrl`. That keeps backpressure
         * coherent — when the server is rate-limiting us, we don't dump more
         * parallelism into the queue.
         */
        this.rawFetch = async (path, init) => {
            if (!path.startsWith("/")) {
                throw new ZapierValidationError(`fetch expects a path starting with '/', got: ${path}`);
            }
            const { url, pathConfig } = this.buildUrl(path, init?.searchParams);
            return this.withSemaphore({ url, method: init?.method ?? "GET", signal: init?.signal }, () => this.rawFetchUrl(url, init, pathConfig));
        };
        /**
         * Approval-aware HTTP fetch.
         *
         * Wraps `rawFetch` with the backend's just-in-time approval protocol. The
         * backend signals approval state via a 403 response with an
         * `x-zapier-error-type` header:
         *
         *   - `request_denied_by_policy`  → a policy rule permanently blocks the
         *                                   request; no human can approve it. Throw.
         *   - `approval_required`         → the request needs human approval; the
         *                                   SDK creates an approval, opens the URL
         *                                   (poll mode), waits for resolution, and
         *                                   retries the original request.
         *   - anything else               → not our concern, pass through.
         *
         * The retry loop exists because a single user action can legitimately
         * require multiple sequential approvals (e.g. policies that approve one
         * step at a time). Each iteration is either a first attempt or a post-
         * approval retry; `maxApprovalRetries` bounds the loop as a runaway-loop
         * safeguard.
         *
         * Loop accounting: `attempt` counts rawFetch calls, not approval rounds.
         * With `maxRetries = N` we perform up to `N + 1` rawFetch calls and at
         * most `N` approval rounds. The `attempt === maxRetries` guard ensures we
         * don't run an (N+1)th approval round we'll never consume — if the final
         * retry still returns `approval_required`, we break out and throw
         * `max_retries_exceeded` instead.
         */
        this.fetch = async (path, init) => {
            const maxRetries = this.options.maxApprovalRetries ?? DEFAULT_MAX_APPROVAL_RETRIES;
            // `<= maxRetries` gives us one initial attempt plus `maxRetries` post-
            // approval retries. See "Loop accounting" in the jsdoc above.
            for (let attempt = 0; attempt <= maxRetries; attempt++) {
                const response = await this.rawFetch(path, init);
                // Happy path and non-403 errors: hand the response back untouched. Any
                // other error handling (e.g. 4xx envelope parsing) lives downstream.
                if (response.status !== 403)
                    return response;
                // All approval-related responses are 403 + a typed header. Classify the
                // kind of 403 we got exactly once, here — no other call site should be
                // reading `x-zapier-error-type`.
                const errorType = response.headers.get("x-zapier-error-type");
                // Terminal policy denial: a rule blocked the request and no approval
                // can unblock it. Bubble the server-provided detail message so callers
                // surface the actual reason ("Spend over $X requires finance approval",
                // etc.) rather than a generic one.
                if (errorType === "request_denied_by_policy") {
                    const { data } = await this.parseResult(response);
                    const { message, errors } = this.parseErrorResponse({
                        status: response.status,
                        statusText: response.statusText,
                        data,
                    });
                    throw new ZapierApprovalError(message || "Request explicitly denied by policy", {
                        status: "policy_denied",
                        statusCode: response.status,
                        errors,
                    });
                }
                // 403 with no `approval_required` header isn't part of the approval
                // protocol — could be auth scope, per-endpoint perms, etc. Pass it
                // through so the caller's normal error handling sees it.
                if (errorType !== "approval_required")
                    return response;
                // We got `approval_required`. If this was our last scheduled attempt,
                // don't start an approval round we can't retry — fall out of the loop
                // and throw `max_retries_exceeded` below.
                if (attempt === maxRetries)
                    break;
                // Resolve the mode: explicit option > env var > TTY-based default.
                const mode = this.options.approvalMode ??
                    getZapierApprovalMode() ??
                    getZapierDefaultApprovalMode();
                if (mode === "disabled") {
                    throw new ZapierApprovalError("Approvals are disabled. Set approvalMode to 'poll' or 'throw' (or ZAPIER_APPROVAL_MODE) to enable.", { status: "approval_required" });
                }
                // `approvalContext` is a builder, not a value, because the SDK may
                // retry the same request across multiple approval rounds and each POST
                // to /approvals needs a freshly-built context (timestamps, etc.).
                // Missing context is a programmer error — this code path is only
                // reachable for callers that opted into approval-capable requests.
                if (!init?.approvalContext) {
                    throw new ZapierApiError(`Received 403 approval_required for ${path}, but the caller did not provide an approvalContext builder. Every approval-capable request must pass approvalContext so the SDK can create the approval with the correct policy context.`, { statusCode: 403 });
                }
                // Create the approval, open the URL (poll mode) or throw with the URL
                // attached (throw mode), and wait for terminal resolution. Returns only
                // on "approved" — every other outcome throws from inside the helper.
                // On return, loop back to retry the original request.
                await this.runOneApprovalRound(init.approvalContext, mode);
            }
            // Only reachable when the final attempt returned `approval_required`
            // (see the `break` above). Every other terminal outcome throws inline.
            throw new ZapierApprovalError(`Exceeded maximum approval retries (${maxRetries}) for ${path}`, { status: "max_retries_exceeded" });
        };
        this.get = async (path, options = {}) => {
            return this.fetchJson("GET", path, undefined, options);
        };
        this.post = async (path, data, options = {}) => {
            return this.fetchJson("POST", path, data, options);
        };
        this.put = async (path, data, options = {}) => {
            return this.fetchJson("PUT", path, data, options);
        };
        this.patch = async (path, data, options = {}) => {
            return this.fetchJson("PATCH", path, data, options);
        };
        this.delete = async (path, data, options = {}) => {
            return this.fetchJson("DELETE", path, data, options);
        };
        this.poll = async (path, options = {}) => {
            return pollUntilComplete({
                fetchPoll: () => this.fetch(path, {
                    method: "GET",
                    searchParams: options.searchParams,
                    authRequired: options.authRequired,
                    signal: options.signal,
                }),
                initialDelay: options.initialDelay,
                timeoutMs: options.timeoutMs,
                successStatus: options.successStatus,
                pendingStatus: options.pendingStatus,
                isPending: options.isPending,
                resultExtractor: options.resultExtractor,
                signal: options.signal,
            });
        };
        this.maxNetworkRetries =
            options.maxNetworkRetries ?? ZAPIER_MAX_NETWORK_RETRIES;
        this.maxNetworkRetryDelayMs =
            options.maxNetworkRetryDelayMs ?? ZAPIER_MAX_NETWORK_RETRY_DELAY_MS;
        // NaN coerces to the default rather than throwing so a CLI flag with a
        // garbage value (parseInt produces NaN) falls back instead of crashing
        // SDK construction. Other invalid values (0, negatives, non-integers,
        // over-cap) still throw so a misconfigured programmatic caller gets a
        // clear error. Peer numeric options like maxNetworkRetries don't coerce
        // NaN — they let it flow into comparisons that misbehave silently. The
        // asymmetry is deliberate.
        const requested = options.maxConcurrentRequests;
        const limit = requested === undefined || Number.isNaN(requested)
            ? ZAPIER_MAX_CONCURRENT_REQUESTS
            : requested;
        if (limit !== Infinity &&
            (!Number.isInteger(limit) || limit < 1 || limit > MAX_CONCURRENCY_LIMIT)) {
            throw new ZapierConfigurationError(`Invalid maxConcurrentRequests: ${limit} (expected positive integer 1-${MAX_CONCURRENCY_LIMIT} or Infinity)`, { configType: "maxConcurrentRequests" });
        }
        this.semaphore = createSemaphore(limit);
    }
    // Emit an event if onEvent handler is configured
    emitEvent(type, payload) {
        if (this.options.onEvent) {
            this.options.onEvent({ type, payload, timestamp: Date.now() });
        }
    }
    // Helper to parse response data
    async parseResult(response) {
        // Read as text first to avoid "Body has already been read" errors
        // if json() fails partway through parsing
        const text = await response.text();
        try {
            return { type: "json", data: JSON.parse(text) };
        }
        catch {
            return { type: "text", data: text };
        }
    }
    // Helper to get a token from the different places it could be gotten
    async getAuthToken(options) {
        return resolveAuthToken({
            credentials: this.options.credentials,
            token: this.options.token,
            onEvent: this.options.onEvent,
            fetch: this.options.fetch,
            baseUrl: this.options.baseUrl,
            requiredScopes: options?.requiredScopes,
            debug: this.options.debug,
            cache: this.options.cache,
        });
    }
    // Helper to handle responses
    async handleResponse(params) {
        const { response, customErrorHandler, resource, wasMissingAuthToken, requiredScopes, } = params;
        const { data: responseData } = await this.parseResult(response);
        if (response.ok) {
            return responseData;
        }
        const errorInfo = {
            status: response.status,
            statusText: response.statusText,
            data: responseData,
        };
        // Precedence: customErrorHandler runs first; if it returns an Error,
        // throw it. If it returns undefined (or wasn't provided), fall through
        // to the resource-enriched / bare defaults below.
        if (customErrorHandler) {
            const customError = customErrorHandler(errorInfo);
            if (customError) {
                // javascript may not respect customErrorHandler return type, so we need to throw a proper error if it's not an Error
                if (customError instanceof Error) {
                    throw customError;
                }
                else {
                    throw new Error(`customErrorHandler returned a non-Error: ${JSON.stringify(customError)}`);
                }
            }
        }
        // Parse the error response
        const { message, errors } = this.parseErrorResponse(errorInfo);
        const errorOptions = {
            statusCode: response.status,
            errors,
        };
        // 404: always throw ZapierResourceNotFoundError (subclass of
        // ZapierNotFoundError, so existing instanceof checks still pass).
        // resourceType/resourceId are populated when the caller passed a
        // `resource` hint, otherwise left undefined. The message is the
        // server's detail verbatim; formatErrorMessage surfaces the resource
        // hint separately (Type: ..., ID: ...) so we don't duplicate it here.
        if (response.status === 404) {
            throw new ZapierResourceNotFoundError(message, {
                ...errorOptions,
                resourceType: resource?.type,
                resourceId: resource?.id !== undefined ? String(resource.id) : undefined,
            });
        }
        if (response.status === 401 || response.status === 403) {
            // If we get a 401/403 error and no auth token was provided, give helpful message
            if (wasMissingAuthToken) {
                throw new ZapierAuthenticationError(`Authentication required (HTTP ${response.status}). Please provide credentials in options or set ZAPIER_CREDENTIALS environment variable.`, errorOptions);
            }
            // On 401, invalidate the cached token so the next request fires
            // a fresh exchange. Passing baseUrl + cache explicitly so the
            // invalidation targets the same cache key the token was stored
            // under (both are part of the key identity).
            if (response.status === 401) {
                await invalidateCredentialsToken({
                    credentials: this.options.credentials,
                    token: this.options.token,
                    baseUrl: this.options.baseUrl,
                    requiredScopes,
                    cache: this.options.cache,
                });
            }
            throw new ZapierAuthenticationError(message, errorOptions);
        }
        if (response.status === 400) {
            throw new ZapierValidationError(message, errorOptions);
        }
        // Generic API error for other status codes
        throw new ZapierApiError(message, errorOptions);
    }
    hasErrorArray(data) {
        return (typeof data === "object" &&
            data !== null &&
            "errors" in data &&
            Array.isArray(data.errors));
    }
    // Helper to check if data has API errors
    isApiErrorArray(dataArray) {
        const data = dataArray[0];
        return (typeof data === "object" &&
            data !== null &&
            "code" in data &&
            "title" in data &&
            "detail" in data);
    }
    // Do our best to extract an error message from the response data
    extractErrorMessage(data) {
        if (typeof data === "string") {
            return data;
        }
        if (typeof data === "object" && data !== null) {
            if ("message" in data && typeof data.message === "string") {
                return data.message;
            }
            if ("error" in data) {
                if (typeof data.error === "string") {
                    return data.error;
                }
                if (typeof data.error === "object" && data.error !== null) {
                    if ("message" in data.error &&
                        typeof data.error.message === "string") {
                        return data.error.message;
                    }
                }
                try {
                    return JSON.stringify(data.error);
                }
                catch {
                    /* defend against circular objects, even though they shouldn't be coming back from the wire */
                }
            }
            if ("errors" in data && Array.isArray(data.errors)) {
                if (this.isApiErrorArray(data.errors)) {
                    return data.errors[0].detail || data.errors[0].title;
                }
                else if (data.errors.length > 0) {
                    // Handle simple string errors array
                    const firstError = data.errors[0];
                    if (typeof firstError === "string") {
                        return firstError;
                    }
                    // For non-string errors, stringify them
                    try {
                        return JSON.stringify(firstError);
                    }
                    catch {
                        return String(firstError);
                    }
                }
            }
        }
        return undefined;
    }
    // Helper to parse API error response
    parseErrorResponse(errorInfo) {
        // If we can't parse data, use status text
        const fallbackMessage = `HTTP ${errorInfo.status}: ${errorInfo.statusText}`;
        try {
            // Check if the response has the standard error format
            if (typeof errorInfo.data === "string") {
                return { message: `${fallbackMessage}: ${errorInfo.data}` };
            }
            const errorMessage = this.extractErrorMessage(errorInfo.data) || fallbackMessage;
            if (this.hasErrorArray(errorInfo.data)) {
                if (this.isApiErrorArray(errorInfo.data.errors)) {
                    return {
                        message: errorMessage,
                        errors: errorInfo.data.errors,
                    };
                }
                else {
                    return {
                        message: errorMessage,
                        errors: errorInfo.data.errors.map((e) => ({
                            status: errorInfo.status,
                            code: String(errorInfo.status),
                            title: errorInfo.statusText,
                            detail: JSON.stringify(e),
                        })),
                    };
                }
            }
            return { message: errorMessage };
        }
        catch {
            return { message: fallbackMessage };
        }
    }
    // Apply any special routing logic for configured paths.
    applyPathConfiguration(path) {
        // Find matching path configuration.
        const matchingPathKey = Object.keys(pathConfig).find((configPath) => path === configPath || path.startsWith(configPath + "/"));
        const config = matchingPathKey ? pathConfig[matchingPathKey] : undefined;
        // Apply pathPrefix if there's a matching config with pathPrefix.
        let finalPath = path;
        if (config &&
            "pathPrefix" in config &&
            config.pathPrefix &&
            matchingPathKey) {
            // Strip the matching path key, and use the pathPrefix instead.
            const pathWithoutPrefix = path.slice(matchingPathKey.length) || "/";
            finalPath = `${config.pathPrefix}${pathWithoutPrefix}`;
        }
        // Check if baseUrl is a Zapier-inferred base URL.
        const zapierBaseUrl = getZapierBaseUrl(this.options.baseUrl);
        // Let's remain compatible with a base URL that is set to a Zapier-inferred
        // domain, rather than requiring the base URL to go to our proxy. Later, the
        // proxy will be removed, so this should make that transition easier.
        if (zapierBaseUrl === this.options.baseUrl.replace(/\/$/, "")) {
            // If baseUrl is already the Zapier base URL, use sdkapi subdomain.
            const originalBaseUrl = new URL(this.options.baseUrl);
            const finalBaseUrl = `https://sdkapi.${originalBaseUrl.hostname}`;
            return {
                url: new URL(finalPath, finalBaseUrl),
                pathConfig: config,
            };
        }
        // For a base URL that isn't a Zapier-inferred domain (e.g., localhost),
        // preserve any path from the base URL and append the final path.
        const baseUrl = new URL(this.options.baseUrl);
        const basePath = baseUrl.pathname.replace(/\/$/, "");
        return {
            url: new URL(basePath + finalPath, baseUrl.origin),
            pathConfig: config,
        };
    }
    // Helper to build full URLs and return routing info
    buildUrl(path, searchParams) {
        const { url, pathConfig: config } = this.applyPathConfiguration(path);
        if (searchParams) {
            Object.entries(searchParams).forEach(([key, value]) => {
                url.searchParams.set(key, value);
            });
        }
        return { url: url.toString(), pathConfig: config };
    }
    // Helper to build headers
    async buildHeaders(options = {}, pathConfig) {
        const headers = new Headers(options.headers ?? {});
        // Even if auth is not required, we still want to add it in case it adds
        // useful context to the API. The session is a good example of this. Auth
        // is not required, but if we don't add auth, then we won't get the user's
        // session!
        const authToken = await this.getAuthToken({
            requiredScopes: options.requiredScopes,
        });
        if (authToken) {
            const authHeaderName = pathConfig && pathConfig.authHeader
                ? pathConfig.authHeader
                : "Authorization";
            headers.set(authHeaderName, getAuthorizationHeader(authToken));
        }
        // If we know auth is required, and we don't have a token, throw an error
        // before we even make a request.
        if (options.authRequired) {
            const authHeaderName = pathConfig?.authHeader ?? "Authorization";
            if (headers.get(authHeaderName) == null && authToken == null) {
                const message = isCliLoginAvailable() === false
                    ? "Authentication required but no credentials available. " +
                        "To use CLI login, install the CLI as a dev dependency " +
                        "(e.g. `npm install -D @zapier/zapier-sdk-cli`) and " +
                        "log in (e.g. `npx zapier-sdk login`). " +
                        "Alternatively, set the ZAPIER_CREDENTIALS environment variable " +
                        "or ZAPIER_CREDENTIALS_CLIENT_ID and " +
                        "ZAPIER_CREDENTIALS_CLIENT_SECRET environment variables."
                    : "Authentication required but no credentials available. " +
                        "Please log in (e.g. `npx zapier-sdk login`) or set the " +
                        "ZAPIER_CREDENTIALS environment variable or " +
                        "ZAPIER_CREDENTIALS_CLIENT_ID and " +
                        "ZAPIER_CREDENTIALS_CLIENT_SECRET environment variables.";
                throw new ZapierAuthenticationError(message);
            }
        }
        return headers;
    }
    // Telemetry headers consumed by sdkapi's ApiRequestCompletedEvent.
    // Applied at the outbound layer (after caller-supplied header merging) so
    // they always reflect the SDK's actual identity and can't be spoofed via
    // options.headers.
    applyTelemetryHeaders(headers) {
        headers.set("x-zapier-sdk-version", SDK_VERSION);
        const sdkService = getZapierSdkService();
        if (sdkService) {
            headers.set("x-zapier-service", sdkService);
        }
        const callerPackage = this.options.callerPackage;
        if (callerPackage) {
            headers.set("x-zapier-sdk-package", callerPackage.name);
            headers.set("x-zapier-sdk-package-version", callerPackage.version);
        }
    }
    // Helper to perform HTTP requests with JSON handling
    async fetchJson(method, path, data, options = {}) {
        const headers = { ...options.headers };
        // Add Content-Type for JSON requests with body data
        if (data && typeof data === "object") {
            headers["Content-Type"] = "application/json";
        }
        // Check if we have an auth token available
        const wasMissingAuthToken = options.authRequired &&
            (await this.getAuthToken({
                requiredScopes: options.requiredScopes,
            })) == null;
        const response = await this.fetch(path, {
            ...options,
            method,
            body: data != null ? JSON.stringify(data) : undefined,
            headers,
        });
        const result = await this.handleResponse({
            response,
            customErrorHandler: options.customErrorHandler,
            resource: options.resource,
            wasMissingAuthToken,
            requiredScopes: options.requiredScopes,
        });
        // Allow empty responses for DELETE or 204 No Content
        if (typeof result === "string") {
            if (result === "" && (method === "DELETE" || response.status === 204)) {
                return undefined;
            }
            throw new ZapierValidationError(`Response could not be parsed as JSON: ${result}`);
        }
        return result;
    }
    /**
     * Run a single approval round: create the approval, open the URL (poll mode)
     * or throw (throw mode), poll until resolved, and emit events. Throws on
     * denied/timeout/unexpected status. Returns on approved.
     *
     * Caller is responsible for passing a non-"disabled" mode; this method
     * unconditionally creates an approval.
     */
    async runOneApprovalRound(buildContext, mode) {
        const context = buildContext();
        // Route through rawFetch so the approval POST shares auth + 429 retry.
        let approvalResponse;
        try {
            approvalResponse = await this.rawFetch("/api/v0/approvals", {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                    Accept: "application/json",
                },
                body: JSON.stringify({ context }),
            });
        }
        catch (err) {
            throw new ZapierApiError("Failed to create approval request", {
                statusCode: 0,
                cause: err,
            });
        }
        if (!approvalResponse.ok) {
            const body = await approvalResponse.text().catch(() => undefined);
            throw new ZapierApiError(`Failed to create approval request: ${approvalResponse.status}`, { statusCode: approvalResponse.status, response: body });
        }
        let body;
        try {
            body = await approvalResponse.text();
        }
        catch (err) {
            throw new ZapierApiError("Failed to read approval response body", {
                statusCode: approvalResponse.status,
                cause: err,
            });
        }
        let approval;
        try {
            approval = CreateApprovalResponseSchema.parse(JSON.parse(body));
        }
        catch (err) {
            throw new ZapierApiError(`Failed to parse approval response: ${body}`, {
                statusCode: approvalResponse.status,
                cause: err,
                response: body,
            });
        }
        // Origin-pin server-supplied URLs before we attach the bearer token
        // (poll_url) or open them in the user's browser (approval_url). Without
        // this, a compromised or buggy sdkapi response could redirect the SDK to
        // an attacker-controlled host and leak the auth token.
        const sdkapiOrigin = new URL(this.buildUrl("/api/v0/approvals").url).origin;
        const browserOrigin = getZapierBaseUrl(this.options.baseUrl) ?? sdkapiOrigin;
        const assertApprovalOrigin = (url, expectedOrigin, label) => {
            let parsed;
            try {
                parsed = new URL(url);
            }
            catch {
                throw new ZapierApiError(`Invalid approval ${label}: ${url}`, {
                    statusCode: approvalResponse.status,
                    response: body,
                });
            }
            if (parsed.origin !== expectedOrigin) {
                throw new ZapierApiError(`Approval ${label} origin ${parsed.origin} does not match expected ${expectedOrigin}`, { statusCode: approvalResponse.status, response: body });
            }
        };
        // In local dev the SDK API, approvalsapi, and approval frontend run on
        // different localhost ports, so neither origin can match. The check
        // exists to stop a compromised sdkapi from redirecting bearer-token
        // traffic to an attacker host — that threat model doesn't apply when
        // the dev owns every service on the machine.
        if (!isLocalhostBaseUrl(this.options.baseUrl)) {
            assertApprovalOrigin(approval.poll_url, sdkapiOrigin, "poll_url");
            assertApprovalOrigin(approval.approval_url, browserOrigin, "approval_url");
        }
        this.emitEvent("approval:required", {
            approvalId: approval.approval_id,
            approvalUrl: approval.approval_url,
        });
        if (mode === "throw") {
            throw new ZapierApprovalError("This request requires approval.", {
                approvalId: approval.approval_id,
                approvalUrl: approval.approval_url,
                pollUrl: approval.poll_url,
                status: "pending",
            });
        }
        // mode === "poll" — open browser and poll
        await openApproval(approval.approval_url);
        const timeoutMs = this.options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
        let rawPollResult;
        try {
            rawPollResult = await pollUntilComplete({
                // poll_url is an absolute URL supplied by the server, so we use
                // rawFetchUrl directly (skipping path resolution) but still share
                // auth + interactive-header + 429-retry with the rest of the SDK.
                // Each individual poll request goes through the concurrency
                // semaphore — but we deliberately do not hold a slot across the
                // sleep between polls or across the human-approval wait.
                fetchPoll: () => this.withSemaphore({ url: approval.poll_url, method: "GET" }, () => this.rawFetchUrl(approval.poll_url, {
                    method: "GET",
                    headers: { Accept: "application/json" },
                })),
                timeoutMs,
                isPending: (body) => {
                    const parsed = PollApprovalResponseSchema.safeParse(body);
                    return parsed.success && parsed.data.status === "pending_approval";
                },
            });
        }
        catch (err) {
            if (!(err instanceof ZapierTimeoutError)) {
                throw err;
            }
            this.emitEvent("approval:timeout", {
                approvalId: approval.approval_id,
            });
            throw new ZapierApprovalError(`Approval timed out after ${timeoutMs / 1000} seconds`, {
                approvalId: approval.approval_id,
                approvalUrl: approval.approval_url,
                pollUrl: approval.poll_url,
                status: "timeout",
                cause: err,
            });
        }
        const pollParse = PollApprovalResponseSchema.safeParse(rawPollResult);
        if (!pollParse.success) {
            const bodyPreview = typeof rawPollResult === "string"
                ? rawPollResult
                : JSON.stringify(rawPollResult);
            throw new ZapierApiError(`Failed to parse approval poll response: ${bodyPreview}`, {
                statusCode: 0,
                cause: pollParse.error,
                response: rawPollResult,
            });
        }
        const pollResult = pollParse.data;
        if (pollResult.status === "denied") {
            this.emitEvent("approval:denied", {
                approvalId: approval.approval_id,
            });
            throw new ZapierApprovalError("Request denied by user", {
                approvalId: approval.approval_id,
                status: "denied",
            });
        }
        if (pollResult.status !== "approved") {
            throw new ZapierApiError(`Unexpected approval status received: ${pollResult.status}`);
        }
        this.emitEvent("approval:approved", {
            approvalId: approval.approval_id,
        });
    }
}
export const createZapierApi = (options) => {
    const { debug = false, fetch: originalFetch = globalThis.fetch } = options;
    const debugLog = createDebugLogger(debug);
    const debugFetch = createDebugFetch({ originalFetch, debugLog });
    return new ZapierApiClient({
        ...options,
        debug,
        fetch: debugFetch,
    });
};
