/**
 * SDK Authentication Utilities
 *
 * This module provides SDK-level authentication utilities focused
 * on token acquisition. It uses the credentials system for resolution
 * and handles different credential types appropriately.
 *
 * CLI-specific functionality like login/logout is handled by the
 * @zapier/zapier-sdk-cli package (imported via its `./login` subpath).
 */
import { isClientCredentials, isPkceCredentials } from "./types/credentials";
import { ZapierAuthenticationError } from "./types/errors";
import { emitOnce } from "./utils/telemetry";
import { resolveCredentials, getClientIdFromCredentials } from "./credentials";
import { createMemoryCache } from "./cache";
const DEFAULT_AUTH_BASE_URL = "https://zapier.com";
export { createMemoryCache } from "./cache";
export { isClientCredentials, isPkceCredentials, isCredentialsObject, isCredentialsFunction, } from "./types/credentials";
/**
 * In-flight token exchange promises to prevent duplicate exchanges
 * inside the same process. When an exchange is in progress, subsequent
 * calls await the same promise. Cache backends are responsible for
 * cross-process dedup via their optional `withLock` method.
 */
const pendingExchanges = new Map();
/**
 * The resolved default cache adapter. Cached at module scope so
 * repeated calls to `resolveAuthToken` reuse the same provider.
 */
let cachedDefaultCache;
/**
 * Build the cache key for a client_credentials identity. baseUrl is
 * part of the key so the same clientId+scopes against different auth
 * hosts (prod vs staging) never alias.
 */
function buildCacheKey(options) {
    const sortedScopes = [...options.scopes].sort().join(",");
    return `zapier-sdk/client-credentials/${options.clientId}:${sortedScopes}:${options.baseUrl}`;
}
/**
 * Clear in-process caches. Useful for testing or to force the next
 * resolve to re-import the default cache adapter. Does not touch
 * persistent cache — use `invalidateCachedToken` for that.
 */
export function clearTokenCache() {
    pendingExchanges.clear();
    cachedCliLogin = undefined;
    cachedDefaultCache = undefined;
}
const TOKEN_EXPIRATION_BUFFER_MS = 5 * 60 * 1000;
/**
 * Decide which cache adapter to use for this call. Priority:
 *   1. explicit `options.cache`
 *   2. CLI's default cache provider (if installed)
 *   3. in-memory fallback
 */
async function resolveCache(options) {
    if (options.cache)
        return options.cache;
    if (cachedDefaultCache !== undefined)
        return cachedDefaultCache;
    const cliLogin = await getCliLogin();
    if (cliLogin?.createCache) {
        try {
            const cache = cliLogin.createCache();
            if (cache) {
                cachedDefaultCache = cache;
                return cache;
            }
        }
        catch {
            // Fall through to in-memory if the CLI provider can't be constructed.
        }
    }
    const fallback = createMemoryCache();
    cachedDefaultCache = fallback;
    return fallback;
}
function entryIsValid(entry) {
    if (entry.expiresAt === undefined)
        return true;
    return entry.expiresAt > Date.now() + TOKEN_EXPIRATION_BUFFER_MS;
}
async function readCachedToken(cacheKey, cache) {
    const cached = await cache.get(cacheKey);
    if (cached && entryIsValid(cached))
        return cached.value;
    return undefined;
}
/**
 * Invalidate the cached token for a given client_credentials identity.
 * Called on 401 so the next request re-exchanges. Clears both the
 * in-process pending-exchange map and the persistent layer via the
 * resolved cache adapter.
 */
export async function invalidateCachedToken(options) {
    const cacheKey = buildCacheKey(options);
    pendingExchanges.delete(cacheKey);
    const cache = await resolveCache({ cache: options.cache });
    try {
        await cache.delete(cacheKey);
    }
    catch {
        // Best-effort: the caller is about to surface a 401 to its own
        // caller; a failed invalidation shouldn't add noise on top of that.
    }
}
/**
 * Get the token endpoint URL for client credentials exchange.
 */
function getTokenEndpointUrl(baseUrl) {
    const base = baseUrl || DEFAULT_AUTH_BASE_URL;
    return `${base}/oauth/token/`;
}
/**
 * Merge and deduplicate scopes from credentials and required scopes.
 * If no credentials scope is specified, defaults to "external".
 * Returns a sorted array of unique scopes.
 */
function mergeScopes(credentialsScope, requiredScopes) {
    const scopeSet = new Set();
    // Add scopes from credentials (space-separated string)
    // If no credentials scope specified, default to "external"
    if (credentialsScope) {
        for (const s of credentialsScope.split(" ")) {
            if (s.trim()) {
                scopeSet.add(s.trim());
            }
        }
    }
    else {
        scopeSet.add("external");
    }
    // Add required scopes
    if (requiredScopes) {
        for (const s of requiredScopes) {
            scopeSet.add(s);
        }
    }
    return [...scopeSet].sort();
}
async function exchangeClientCredentials(options) {
    const { clientId, clientSecret, baseUrl, scope, requiredScopes, onEvent } = options;
    const fetchFn = options.fetch || globalThis.fetch;
    const tokenUrl = getTokenEndpointUrl(baseUrl);
    // Merge credentials scope with required scopes
    const mergedScopes = mergeScopes(scope, requiredScopes);
    const scopeString = mergedScopes.join(" ");
    onEvent?.({
        type: "auth_exchanging",
        payload: {
            message: "Exchanging client credentials for token...",
            operation: "client_credentials",
        },
        timestamp: Date.now(),
    });
    const response = await fetchFn(tokenUrl, {
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
            grant_type: "client_credentials",
            client_id: clientId,
            client_secret: clientSecret,
            scope: scopeString,
            audience: "zapier.com",
        }),
    });
    if (!response.ok) {
        const errorText = await response.text();
        onEvent?.({
            type: "auth_error",
            payload: {
                message: `Client credentials exchange failed: ${response.status}`,
                error: errorText,
                operation: "client_credentials",
            },
            timestamp: Date.now(),
        });
        throw new Error(`Client credentials exchange failed: ${response.status} ${response.statusText}`);
    }
    const data = await response.json();
    if (!data.access_token) {
        throw new Error("Client credentials response missing access_token");
    }
    onEvent?.({
        type: "auth_success",
        payload: {
            message: "Client credentials exchange successful",
            operation: "client_credentials",
        },
        timestamp: Date.now(),
    });
    return {
        accessToken: data.access_token,
        expiresIn: data.expires_in || 3600,
    };
}
let cachedCliLogin;
/**
 * Dynamically imports the CLI's login/cache entrypoint with caching.
 * Returns the module if the CLI package is installed in the consumer's
 * environment, or `undefined` if not (e.g. in a browser or minimal
 * server deployment). The result is cached after the first attempt.
 */
async function getCliLogin() {
    if (cachedCliLogin !== undefined) {
        return cachedCliLogin || undefined;
    }
    try {
        const mod = await import("@zapier/zapier-sdk-cli/login");
        if (typeof mod.getToken === "function") {
            cachedCliLogin = mod;
            return cachedCliLogin;
        }
    }
    catch {
        // Not available (no CLI installed in this environment).
    }
    cachedCliLogin = false;
    return undefined;
}
/**
 * Inject an already-loaded CLI login module so the SDK skips its dynamic import.
 * This guarantees CLI and SDK share the same module instance in the same process.
 */
export function injectCliLogin(module) {
    cachedCliLogin = module;
}
/**
 * Returns whether a CLI login package is available.
 * `undefined` if no import has been attempted yet, `true` if found, `false` if not.
 */
export function isCliLoginAvailable() {
    if (cachedCliLogin === undefined)
        return undefined;
    return cachedCliLogin !== false;
}
function emitAuthResolved(onEvent, mechanism) {
    if (onEvent) {
        emitOnce(onEvent, {
            type: "auth_resolved",
            payload: { mechanism },
            timestamp: Date.now(),
        });
    }
}
async function getActiveCredentialsFromCli(baseUrl) {
    const cliLogin = await getCliLogin();
    return cliLogin?.getActiveCredentials?.({ baseUrl });
}
async function getStoredClientCredentialsFromCli(baseUrl) {
    const cliLogin = await getCliLogin();
    return cliLogin?.getStoredClientCredentials?.({ baseUrl });
}
/**
 * Attempts to get a token from the CLI login package.
 *
 * Returns undefined if no valid token is found or no login package is available.
 * Throws if there's an actual error (like token refresh failure).
 */
export async function getTokenFromCliLogin(options) {
    const cliLogin = await getCliLogin();
    if (!cliLogin)
        return undefined;
    return await cliLogin.getToken(options);
}
async function tryStoredClientCredentialToken(options) {
    const activeCredential = await getActiveCredentialsFromCli(options.baseUrl);
    if (!activeCredential)
        return undefined;
    const resolvedBaseUrl = activeCredential.baseUrl || options.baseUrl || DEFAULT_AUTH_BASE_URL;
    const mergedScopes = mergeScopes(activeCredential.scopes.join(" "), options.requiredScopes);
    const cacheKey = buildCacheKey({
        clientId: activeCredential.clientId,
        scopes: mergedScopes,
        baseUrl: resolvedBaseUrl,
    });
    const cache = await resolveCache(options);
    const pending = pendingExchanges.get(cacheKey);
    if (pending)
        return pending;
    const cached = await readCachedToken(cacheKey, cache);
    if (cached !== undefined) {
        if (options.debug)
            console.log(`[auth] Using cached token (clientId: ${activeCredential.clientId})`);
        emitAuthResolved(options.onEvent, "client_credentials");
        return cached;
    }
    const storedCredential = await getStoredClientCredentialsFromCli(resolvedBaseUrl);
    if (!storedCredential) {
        // Active credential pointer exists but the keychain secret is missing.
        // Falling back to JWT would silently downgrade a migrated user; surface
        // the broken state so they re-run login to recreate the secret.
        await invalidateCachedToken({
            clientId: activeCredential.clientId,
            scopes: activeCredential.scopes,
            baseUrl: resolvedBaseUrl,
            cache: options.cache,
        });
        throw new ZapierAuthenticationError(`Stored client credential is missing its secret (clientId: ${activeCredential.clientId}). Run \`zapier-sdk login\` to recreate it.`);
    }
    if (options.debug)
        console.log(`[auth] Using stored client credential (clientId: ${storedCredential.clientId})`);
    const token = await resolveAuthTokenFromCredentials(storedCredential, options);
    emitAuthResolved(options.onEvent, "client_credentials");
    return token;
}
/**
 * Resolves an auth token from wherever it can be found.
 *
 * Resolution order:
 * 1. Explicit credentials option (or deprecated token option)
 * 2. Environment variables
 * 3. CLI login package (if available)
 *
 * For different credential types:
 * - String: Used directly as the token
 * - Client credentials: Exchanged for an access token (with caching)
 * - PKCE: Delegates to CLI login (throws if not available)
 *
 * Returns undefined if no valid token is found.
 */
export async function resolveAuthToken(options = {}) {
    // Resolve credentials from options and env vars
    const credentials = await resolveCredentials({
        credentials: options.credentials,
        token: options.token,
        baseUrl: options.baseUrl,
    });
    // If we got credentials, process them based on type
    if (credentials !== undefined) {
        return resolveAuthTokenFromCredentials(credentials, options);
    }
    const storedToken = await tryStoredClientCredentialToken(options);
    if (storedToken !== undefined)
        return storedToken;
    if (options.debug) {
        console.log("[auth] Using JWT (no stored client credential found)");
    }
    const jwtToken = await getTokenFromCliLogin({
        onEvent: options.onEvent,
        fetch: options.fetch,
        debug: options.debug,
    });
    if (jwtToken !== undefined) {
        emitAuthResolved(options.onEvent, "jwt");
    }
    return jwtToken;
}
/**
 * Resolve an auth token from resolved credentials.
 */
async function resolveAuthTokenFromCredentials(credentials, options) {
    if (typeof credentials === "string") {
        emitAuthResolved(options.onEvent, "token");
        return credentials;
    }
    // Client credentials: exchange + cache through a pluggable cache
    // adapter. Resolution order:
    //   1. in-process pending exchange (dedup concurrent same-process calls)
    //   2. cache.get (hits keychain + config under the filesystem adapter,
    //      RAM under the in-memory adapter, whatever the user adapter does)
    //   3. cache.withLock (if available) -> re-read, exchange, persist
    //      (so N parallel processes collapse to 1 network exchange)
    //   4. plain exchange + persist when no lock is available
    if (isClientCredentials(credentials)) {
        const { clientId } = credentials;
        const mergedScopes = mergeScopes(credentials.scope, options.requiredScopes);
        const resolvedBaseUrl = credentials.baseUrl || options.baseUrl || DEFAULT_AUTH_BASE_URL;
        const cacheKey = buildCacheKey({
            clientId,
            scopes: mergedScopes,
            baseUrl: resolvedBaseUrl,
        });
        const cache = await resolveCache(options);
        // Fast-path read
        const cached = await readCachedToken(cacheKey, cache);
        if (cached !== undefined) {
            if (options.debug) {
                console.log(`[auth] Using cached token (clientId: ${clientId})`);
            }
            return cached;
        }
        // In-process dedup
        const pending = pendingExchanges.get(cacheKey);
        if (pending)
            return pending;
        // Serialize exchange + persist inside the lock (when the adapter
        // supports cross-process locking). The second acquirer re-reads the
        // cache under the lock and uses the first's token instead of firing
        // another exchange.
        const runLocked = async () => {
            const recheck = await readCachedToken(cacheKey, cache);
            if (recheck !== undefined) {
                if (options.debug) {
                    console.log(`[auth] Using cached token (clientId: ${clientId}, locked recheck)`);
                }
                return recheck;
            }
            const { accessToken, expiresIn } = await exchangeClientCredentials({
                clientId: credentials.clientId,
                clientSecret: credentials.clientSecret,
                baseUrl: credentials.baseUrl || options.baseUrl,
                scope: credentials.scope,
                requiredScopes: options.requiredScopes,
                fetch: options.fetch,
                onEvent: options.onEvent,
            });
            try {
                await cache.set(cacheKey, accessToken, {
                    secret: true,
                    ttl: expiresIn,
                });
            }
            catch {
                // Best-effort persistence: next process will re-exchange.
            }
            return accessToken;
        };
        const exchangePromise = (cache.withLock ? cache.withLock(cacheKey, runLocked) : runLocked()).finally(() => {
            pendingExchanges.delete(cacheKey);
        });
        pendingExchanges.set(cacheKey, exchangePromise);
        return exchangePromise;
    }
    // PKCE credentials: delegate to CLI login
    if (isPkceCredentials(credentials)) {
        // Try to get stored token from CLI login
        const storedToken = await getTokenFromCliLogin({
            onEvent: options.onEvent,
            fetch: options.fetch,
            credentials,
            debug: options.debug,
        });
        if (storedToken) {
            return storedToken;
        }
        // No stored token - user needs to run login
        throw new Error("PKCE credentials require interactive login. " +
            "Please run the 'login' command with the CLI first, or use client_credentials flow.");
    }
    // Should not reach here, but handle gracefully
    throw new Error("Unknown credentials type");
}
/**
 * Invalidate the cached token for the given credentials, if applicable.
 * This is called when we receive a 401 response, indicating the token
 * is no longer valid. Only affects client_credentials flow tokens.
 */
export async function invalidateCredentialsToken(options) {
    const resolved = await resolveCredentials(options);
    if (!resolved)
        return;
    const clientId = getClientIdFromCredentials(resolved);
    if (clientId && isClientCredentials(resolved)) {
        const scopes = mergeScopes(resolved.scope, options.requiredScopes);
        const baseUrl = resolved.baseUrl || options.baseUrl || DEFAULT_AUTH_BASE_URL;
        await invalidateCachedToken({
            clientId,
            scopes,
            baseUrl,
            cache: options.cache,
        });
    }
}
