/**
 * AutoRefreshCoordinator — automatic token refresh with in-flight request queuing.
 *
 * Prevents user-visible 401s by:
 *   1. Pre-flight leeway check: if the token expires within refreshLeewayMs,
 *      trigger an automatic refresh before the request is dispatched.
 *   2. Reactive 401 retry: if a request returns 401 and the token wasn't
 *      already known expired, trigger a refresh then retry the request once.
 *   3. In-flight queuing: while a refresh is in progress, subsequent refresh
 *      attempts queue on the same promise — one refresh call for all waiters.
 *
 * When no refresh endpoint is available (the coordinator has no `refresh`
 * function), the pre-flight check is a graceful no-op and the reactive path
 * triggers a token re-read (which may succeed if the store was updated externally).
 *
 * Error messages use this three-part format:
 *   [what happened] · [why] · [what to do]
 */
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
import { invokeObserver } from '../observer/index.js';
// ---------------------------------------------------------------------------
// Coordinator implementation
// ---------------------------------------------------------------------------
export class AutoRefreshCoordinator {
    #tokenStore;
    #autoRefresh;
    #refreshLeewayMs;
    #refresh;
    #observer;
    /** Promise shared across all waiters during an active refresh. */
    #refreshingPromise = null;
    constructor(options) {
        this.#tokenStore = options.tokenStore;
        this.#autoRefresh = options.autoRefresh;
        this.#refreshLeewayMs = options.refreshLeewayMs;
        this.#refresh = options.refresh;
        this.#observer = options.observer;
    }
    // ---------------------------------------------------------------------------
    // Token expiry helpers
    // ---------------------------------------------------------------------------
    /** Read the raw token entry, including optional expiresAt. */
    async #readEntry() {
        const store = this.#tokenStore;
        if (typeof store.getTokenEntry === 'function') {
            return store.getTokenEntry();
        }
        // Fall back to token-only stores that don't expose expiresAt.
        const token = await this.#tokenStore.getToken();
        return { token };
    }
    /** Return true if the token is within the leeway window of expiry. */
    async #isNearExpiry() {
        const { expiresAt } = await this.#readEntry();
        if (expiresAt === undefined)
            return false;
        return Date.now() + this.#refreshLeewayMs >= expiresAt;
    }
    /** Return true if the token is definitively expired (past expiresAt). */
    async #isExpired() {
        const { expiresAt } = await this.#readEntry();
        if (expiresAt === undefined)
            return false;
        return Date.now() >= expiresAt;
    }
    // ---------------------------------------------------------------------------
    // Core refresh logic (serialised via shared promise)
    // ---------------------------------------------------------------------------
    /**
     * Trigger a refresh. If one is already in progress, all callers wait on the
     * same promise — no duplicate refresh network calls.
     *
     * After a successful refresh, emits `onAuthTransition` reason='refresh'.
     * After a failed refresh, emits `onAuthTransition` reason='expire' and
     * clears the token (falls back to anonymous).
     */
    async #doRefresh() {
        if (this.#refreshingPromise) {
            return this.#refreshingPromise;
        }
        const promise = (async () => {
            if (!this.#refresh) {
                // No refresh endpoint — graceful no-op.
                return;
            }
            try {
                const { token, expiresAt } = await this.#refresh();
                const store = this.#tokenStore;
                if (typeof store.setTokenEntry === 'function') {
                    await store.setTokenEntry(token, expiresAt);
                }
                else {
                    await this.#tokenStore.setToken(token);
                }
                invokeObserver(() => this.#observer?.onAuthTransition?.({
                    from: 'token',
                    to: 'token',
                    reason: 'refresh',
                }));
            }
            catch (err) {
                const refreshError = err instanceof GoodVibesSdkError
                    ? err
                    : new GoodVibesSdkError('Token refresh failed; clearing to anonymous.', {
                        code: 'SDK_AUTH_REFRESH_FAILED',
                        category: 'authentication',
                        source: 'runtime',
                        recoverable: true,
                        cause: err,
                    });
                await this.#tokenStore.clearToken();
                invokeObserver(() => this.#observer?.onAuthTransition?.({
                    from: 'token',
                    to: 'anonymous',
                    reason: 'expire',
                }));
                invokeObserver(() => this.#observer?.onError?.(refreshError), { label: 'onError' });
            }
        })();
        this.#refreshingPromise = promise.finally(() => {
            this.#refreshingPromise = null;
        });
        return this.#refreshingPromise;
    }
    // ---------------------------------------------------------------------------
    // Pre-flight check
    // ---------------------------------------------------------------------------
    /**
     * Call before dispatching a request. If the token is near expiry (within
     * `refreshLeewayMs`), refreshes before the request goes out.
     *
     * If `autoRefresh` is disabled, this is a no-op.
     */
    async ensureFreshToken() {
        if (!this.#autoRefresh)
            return;
        const nearExpiry = await this.#isNearExpiry();
        if (nearExpiry) {
            await this.#doRefresh();
        }
    }
    // ---------------------------------------------------------------------------
    // Reactive 401 retry wrapper
    // ---------------------------------------------------------------------------
    /**
     * Execute `fn` and retry once on 401 after triggering a refresh.
     *
     * If `autoRefresh` is false, the first 401 is rethrown immediately.
     * If the retry also returns 401, throws `GoodVibesSdkError{kind:'auth'}`.
     *
     * @param fn - The request function to execute. Must be side-effect-safe to
     *   call twice (called at most twice).
     */
    async withRetryOn401(fn) {
        try {
            return await fn();
        }
        catch (firstError) {
            if (!this.#autoRefresh || !is401Error(firstError)) {
                throw firstError;
            }
            // Trigger refresh (queued if already in progress).
            const wasExpired = await this.#isExpired();
            await this.#doRefresh();
            // Retry once.
            try {
                return await fn();
            }
            catch (retryError) {
                if (is401Error(retryError)) {
                    // Terminal auth failure.
                    const authErr = new GoodVibesSdkError('Authentication failed · Token is invalid or has expired · ' +
                        (wasExpired
                            ? 'Re-login to obtain a fresh token.'
                            : 'Verify your credentials and token store configuration.'), {
                        category: 'authentication',
                        source: 'transport',
                        status: 401,
                        recoverable: false,
                    });
                    invokeObserver(() => this.#observer?.onError?.(authErr));
                    throw authErr;
                }
                throw retryError;
            }
        }
    }
    // ---------------------------------------------------------------------------
    // Middleware-facing retry helper
    // ---------------------------------------------------------------------------
    /**
     * Refresh the token immediately and execute `fn` exactly once as the retry.
     *
     * Unlike `withRetryOn401`, this method does NOT call `fn` before refreshing —
     * it assumes the caller already received a 401 on the initial attempt. It
     * refreshes the token (serialised via the shared promise, as with
     * `withRetryOn401`) and then calls `fn` a single time.
     *
     * If `fn` throws a 401 on retry, a terminal `GoodVibesSdkError{kind:'auth'}`
     * is thrown with the standard three-part message format.
     *
     * Used by `createAutoRefreshMiddleware` to avoid making an extra HTTP call
     * when the middleware already observed the initial 401 from `next()`.
     *
     * @param fn - The retry request to execute after the refresh completes.
     */
    async refreshAndRetryOnce(fn) {
        const wasExpired = await this.#isExpired();
        // Refresh (serialised — if one is already in progress, join it).
        await this.#doRefresh();
        // Single retry attempt.
        try {
            return await fn();
        }
        catch (retryError) {
            if (is401Error(retryError)) {
                const authErr = new GoodVibesSdkError('Authentication failed · Token is invalid or has expired · ' +
                    (wasExpired
                        ? 'Re-login to obtain a fresh token.'
                        : 'Verify your credentials and token store configuration.'), {
                    category: 'authentication',
                    source: 'transport',
                    status: 401,
                    recoverable: false,
                });
                invokeObserver(() => this.#observer?.onError?.(authErr));
                throw authErr;
            }
            throw retryError;
        }
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function is401Error(error) {
    if (typeof error !== 'object' || error === null)
        return false;
    const e = error;
    const status = e.status ??
        e.transport?.status ??
        e.response?.status ??
        e.cause?.response?.status;
    return status === 401;
}
