import { isDeepStrictEqual } from 'node:util';
import * as Challenge from '../Challenge.js';
import * as Credential from '../Credential.js';
import * as Errors from '../Errors.js';
import * as Expires from '../Expires.js';
import * as AcceptPayment from '../internal/AcceptPayment.js';
import * as Env from '../internal/env.js';
import * as PaymentRequest from '../PaymentRequest.js';
import * as Html from './internal/html/config.js';
import { serviceWorker } from './internal/html/serviceWorker.gen.js';
import * as Scope from './internal/scope.js';
import * as NodeListener from './NodeListener.js';
import * as Request from './Request.js';
import * as Transport from './Transport.js';
/**
 * Creates a server-side payment handler from methods.
 *
 * It is highly recommended to set a `secretKey` to bind challenges to their contents,
 * and allow the server to verify that incoming credentials match challenges it issued.
 *
 * @example
 * ```ts
 * import { Mppx, tempo } from 'mppx/server'
 *
 * const payment = Mppx.create({
 *   methods: [tempo()],
 *   secretKey: process.env.PAYMENT_SECRET_KEY,
 * })
 * ```
 */
export function create(config) {
    const { realm = Env.get('realm'), secretKey = Env.get('secretKey'), transport = Transport.http(), } = config;
    if (!secretKey) {
        throw new Error('Missing secret key. Set the MPP_SECRET_KEY environment variable or pass `secretKey` to Mppx.create().');
    }
    const methods = config.methods.flat();
    const handlers = {};
    const intentCount = {};
    for (const mi of methods) {
        intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1;
        handlers[`${mi.name}/${mi.intent}`] = createMethodFn({
            defaults: mi.defaults,
            method: mi,
            realm,
            request: mi.request,
            respond: mi.respond,
            secretKey,
            transport: (mi.transport ?? transport),
            verify: mi.verify,
        });
    }
    // Also set shorthand intent key when there's no collision
    for (const mi of methods) {
        if (intentCount[mi.intent] === 1)
            handlers[mi.intent] = handlers[`${mi.name}/${mi.intent}`];
    }
    // Build nested handlers: mppx.tempo.charge(...)
    for (const mi of methods) {
        if (!handlers[mi.name])
            handlers[mi.name] = {};
        const fn = handlers[`${mi.name}/${mi.intent}`];
        fn._method = mi;
        handlers[mi.name][mi.intent] = fn;
    }
    // Build challenge generators: mppx.challenge.tempo.charge(...)
    const challengeHandlers = {};
    for (const mi of methods) {
        if (!challengeHandlers[mi.name])
            challengeHandlers[mi.name] = {};
        challengeHandlers[mi.name][mi.intent] = createChallengeFn({
            defaults: mi.defaults,
            method: mi,
            realm,
            request: mi.request,
            secretKey,
        });
    }
    // verifyCredential: single-call end-to-end verification
    async function verifyCredentialFn(input, options) {
        const credential = hydrateCredentialMeta(typeof input === 'string' ? Credential.deserialize(input) : input);
        // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
        if (!Challenge.verify(credential.challenge, { secretKey: secretKey }))
            throw new Errors.InvalidChallengeError({
                id: credential.challenge.id,
                reason: 'challenge was not issued by this server',
            });
        // Expiry check
        Expires.assert(credential.challenge.expires, credential.challenge.id);
        // Find matching method by name + intent
        const { method: credMethod, intent: credIntent } = credential.challenge;
        const mi = methods.find((m) => m.name === credMethod && m.intent === credIntent);
        if (!mi)
            throw new Errors.InvalidChallengeError({
                id: credential.challenge.id,
                reason: `no registered method for ${credMethod}/${credIntent}`,
            });
        // Validate payload against method schema
        mi.schema.credential.payload.parse(credential.payload);
        const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope });
        if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
            throw new Errors.InvalidChallengeError({
                id: credential.challenge.id,
                reason: "credential scope does not match this route's requirements",
            });
        }
        const shouldValidateRoute = options?.capturedRequest !== undefined ||
            options?.meta !== undefined ||
            options?.realm !== undefined ||
            options?.request !== undefined;
        const expectedRealm = options?.realm ??
            realm ??
            (options?.capturedRequest === undefined ? credential.challenge.realm : undefined);
        const request = shouldValidateRoute
            ? await resolveRouteChallenge({
                capturedRequest: options?.capturedRequest,
                credential,
                defaults: mi.defaults,
                expires: credential.challenge.expires,
                meta: expectedMeta,
                method: mi,
                realm: expectedRealm,
                request: mi.request,
                routeRequest: options?.request ?? {},
                secretKey: secretKey,
            }).then((resolved) => {
                const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge);
                if (mismatch)
                    throw new Errors.InvalidChallengeError({
                        id: credential.challenge.id,
                        reason: `credential ${mismatch} does not match this route's requirements`,
                    });
                return resolved.request;
            })
            : credential.challenge.request;
        const envelope = options?.capturedRequest
            ? {
                capturedRequest: options.capturedRequest,
                challenge: credential.challenge,
                credential,
                request,
            }
            : undefined;
        return mi.verify({ credential, envelope, request });
    }
    function composeFn(...entries) {
        if (transport.name !== 'http')
            throw new Error('compose() only supports HTTP transport');
        if (entries.length === 0)
            throw new Error('compose() requires at least one entry');
        const configured = entries.map(([methodOrKey, options]) => {
            const key = typeof methodOrKey === 'string'
                ? methodOrKey
                : typeof methodOrKey === 'function' && '_method' in methodOrKey
                    ? `${methodOrKey._method.name}/${methodOrKey._method.intent}`
                    : `${methodOrKey.name}/${methodOrKey.intent}`;
            const handlerFn = handlers[key];
            if (!handlerFn)
                throw new Error(`No handler for "${key}". Is this method in your methods array?`);
            return handlerFn(options);
        });
        return compose(...configured);
    }
    return {
        methods,
        challenge: challengeHandlers,
        compose: composeFn,
        realm: realm,
        transport,
        verifyCredential: verifyCredentialFn,
        ...handlers,
    };
}
// biome-ignore lint/correctness/noUnusedVariables: _
function createMethodFn(parameters) {
    const { defaults, method, realm, respond, secretKey, transport, verify } = parameters;
    return (options) => {
        const { description, meta, scope, ...rest } = options;
        const staticMeta = Scope.merge({ meta, scope });
        return Object.assign(async (input) => {
            const expires = 'expires' in options ? options.expires : Expires.minutes(5);
            const capturedRequest = await captureRequest(transport, input);
            const effectiveMeta = scope === undefined && input instanceof globalThis.Request
                ? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
                : staticMeta;
            // Extract credential once — getCredential may have side effects (e.g. SSE transports).
            const [credential, credentialError] = (() => {
                try {
                    const credential = transport.getCredential(input);
                    return [credential ? hydrateCredentialMeta(credential) : null, undefined];
                }
                catch (e) {
                    return [null, e];
                }
            })();
            const { challenge, request } = await resolveRouteChallenge({
                capturedRequest,
                credential,
                defaults,
                description,
                expires,
                meta: effectiveMeta,
                method,
                realm,
                request: parameters.request,
                routeRequest: rest,
                secretKey,
            });
            // Credential was provided but malformed
            if (credentialError) {
                const reason = getSafeCredentialReason(credentialError);
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error: new Errors.MalformedCredentialError(reason ? { reason } : {}),
                    html: method.html,
                });
                return { challenge: response, status: 402 };
            }
            // No credential provided—issue challenge
            if (!credential) {
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error: new Errors.PaymentRequiredError({ description }),
                    html: method.html,
                });
                return { challenge: response, status: 402 };
            }
            // ── Tier 1: HMAC provenance check (primary gate) ──────────────────
            //
            // Recompute the HMAC-SHA256 over the credential's echoed challenge
            // parameters (realm|method|intent|request|expires|digest|opaque) and
            // compare to the echoed `id`. This proves the challenge was issued by
            // this server with these exact parameters — including opaque/meta,
            // expires, and the full serialized request blob.
            //
            // This is the authoritative binding per §5.1.2.1.1 of the spec
            // (https://paymentauth.org/draft-httpauth-payment-00.html#section-5.1.2.1.1).
            // No database lookup is needed; the HMAC is stateless verification.
            if (!Challenge.verify(credential.challenge, { secretKey })) {
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error: new Errors.InvalidChallengeError({
                        id: credential.challenge.id,
                        reason: 'challenge was not issued by this server',
                    }),
                    html: method.html,
                });
                return { challenge: response, status: 402 };
            }
            // ── Tier 2: Pinned field safety net ──────────────────────────────
            //
            // The HMAC check above (Tier 1) is the primary gate — it already
            // covers ALL challenge fields including opaque, digest, and the full
            // serialized request. So why this second check?
            //
            // The `request()` hook can produce credential-dependent output: for
            // example, `feePayer` may differ between the 402 challenge call (no
            // credential) and the credential-bearing call. This means the
            // recomputed challenge here has a different `request` blob — and
            // thus a different HMAC — than the original challenge the client
            // echoes back. The HMAC check above verifies the *echoed* challenge
            // was signed by us, but it cannot verify that the echoed challenge
            // matches *this route's current configuration* when the request
            // hook transforms fields between calls.
            //
            // This check compares the fields that MUST be stable across both
            // calls. That includes the economically significant request fields
            // plus `opaque`, which can carry route-scoping metadata (for example,
            // sibling route identity) that must not be replayable across handlers.
            // `expires` still is not pinned here because its default is generated
            // per invocation, and `digest` is already bound by the echoed HMAC.
            {
                const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge);
                if (mismatch) {
                    const response = await transport.respondChallenge({
                        challenge,
                        input,
                        error: new Errors.InvalidChallengeError({
                            id: credential.challenge.id,
                            reason: `credential ${mismatch} does not match this route's requirements`,
                        }),
                        html: method.html,
                    });
                    return { challenge: response, status: 402 };
                }
            }
            // Reject credentials without expires (fail-closed) or with expired timestamp
            try {
                Expires.assert(credential.challenge.expires, credential.challenge.id);
            }
            catch (error) {
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error: error,
                });
                return { challenge: response, status: 402 };
            }
            // Validate payload structure against method schema
            try {
                method.schema.credential.payload.parse(credential.payload);
            }
            catch {
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error: new Errors.InvalidPayloadError(),
                });
                return { challenge: response, status: 402 };
            }
            const envelope = Object.freeze({
                capturedRequest,
                challenge: credential.challenge,
                credential,
                request,
            });
            // User-provided verification (e.g., check signature, submit tx, verify payment).
            // If verification fails, re-issue the challenge so the client can retry.
            let receiptData;
            try {
                receiptData = await verify({ credential, envelope, request });
            }
            catch (e) {
                if (!(e instanceof Errors.PaymentError))
                    console.error('mppx: internal verification error', e);
                const error = e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError();
                const response = await transport.respondChallenge({
                    challenge,
                    input,
                    error,
                });
                return { challenge: response, status: 402 };
            }
            // If the method's `respond` hook returns a Response, it means this
            // request is a management action (e.g. channel open, voucher POST)
            // and the user's route handler should NOT run. `withReceipt()` will
            // return the management response directly. If undefined, `withReceipt()`
            // expects the caller to pass the user handler's response instead.
            const managementResponse = respond
                ? await respond({ credential, envelope, input, receipt: receiptData, request })
                : undefined;
            return {
                status: 200,
                withReceipt(response) {
                    if (managementResponse) {
                        return transport.respondReceipt({
                            challengeId: credential.challenge.id,
                            credential,
                            envelope,
                            input,
                            receipt: receiptData,
                            response: managementResponse,
                        });
                    }
                    if (!response)
                        throw new Error('withReceipt() requires a response argument');
                    return transport.respondReceipt({
                        challengeId: credential.challenge.id,
                        credential,
                        envelope,
                        input,
                        receipt: receiptData,
                        response: response,
                    });
                },
            };
        }, {
            _internal: {
                ...method,
                ...defaults,
                ...options,
                ...(staticMeta !== undefined ? { meta: staticMeta } : {}),
                name: method.name,
                intent: method.intent,
                _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
            },
        });
    };
}
/**
 * Creates a challenge generator for a single method+intent.
 * Applies the same defaults and request transform as createMethodFn,
 * but returns a Challenge object directly instead of a request handler.
 */
function createChallengeFn(parameters) {
    const { defaults, method, realm, secretKey } = parameters;
    return async (options) => {
        const { description, meta, scope, ...rest } = options;
        const effectiveMeta = Scope.merge({ meta, scope });
        const expires = 'expires' in options ? options.expires : Expires.minutes(5);
        return resolveRouteChallenge({
            defaults,
            description,
            expires,
            meta: effectiveMeta,
            method,
            realm,
            request: parameters.request,
            routeRequest: rest,
            secretKey,
        }).then((resolved) => resolved.challenge);
    };
}
function getSafeCredentialReason(error) {
    if (error instanceof Credential.InvalidCredentialEncodingError)
        return error.message;
    if (error instanceof Credential.MissingAuthorizationHeaderError)
        return error.message;
    if (error instanceof Credential.MissingPaymentSchemeError)
        return error.message;
    return undefined;
}
const defaultRealm = 'MPP Payment';
const Warnings = {
    realmFallback: 'realm-fallback',
};
const _warned = new Set();
function warnOnce(key, message) {
    if (_warned.has(key))
        return;
    _warned.add(key);
    console.warn(`[mppx] ${message}`);
}
/** Extracts hostname from the captured request URL, falling back to a default. */
function resolveRealmFromCapturedRequest(capturedRequest) {
    try {
        const { protocol, hostname } = capturedRequest.url;
        if (/^https?:$/.test(protocol) && hostname)
            return hostname;
    }
    catch { }
    warnOnce(Warnings.realmFallback, `Could not auto-detect realm from request. Falling back to "${defaultRealm}". Set \`realm\` in Mppx.create() or the MPP_REALM env var.`);
    return defaultRealm;
}
async function resolveRouteChallenge(parameters) {
    // Resolve the route's canonical request exactly as the handler path does:
    const request = await (async () => {
        // start from defaults + route options, then let the method request hook
        const merged = { ...parameters.defaults, ...parameters.routeRequest };
        // normalize or enrich it using the captured request and credential.
        return parameters.request
            ? (await parameters.request({
                capturedRequest: parameters.capturedRequest,
                credential: parameters.credential,
                request: merged,
            }))
            : merged;
    })();
    const effectiveRealm = parameters.realm ??
        (parameters.capturedRequest
            ? resolveRealmFromCapturedRequest(parameters.capturedRequest)
            : defaultRealm);
    return {
        challenge: Challenge.fromMethod(parameters.method, {
            description: parameters.description,
            expires: parameters.expires,
            meta: parameters.meta,
            realm: effectiveRealm,
            request: request,
            secretKey: parameters.secretKey,
        }),
        request,
    };
}
/**
 * Captures the transport request into a frozen snapshot at the start of the
 * verification flow. This snapshot is threaded through request() → verify() →
 * respond() → respondReceipt() so every hook sees the same authoritative
 * request state — preventing the raw transport input from being re-read or
 * mutated between verification steps.
 *
 * Note: Object.freeze is shallow — it prevents reassigning top-level properties
 * but does not deep-freeze mutable class instances like Headers or URL. This is
 * an accidental-mutation guard for trusted server hooks, not a security boundary.
 */
async function captureRequest(transport, input) {
    const capturedRequest = transport.captureRequest
        ? await transport.captureRequest(input)
        : captureRequestFromInput(input);
    return Object.freeze(capturedRequest);
}
function captureRequestFromInput(input) {
    const source = input;
    return {
        headers: new Headers(source.headers),
        hasBody: source.body === undefined ? undefined : source.body !== null,
        method: source.method ?? 'POST',
        url: Transport.safeUrl(source.url),
    };
}
const coreBindingFields = ['amount', 'currency', 'recipient'];
const methodBindingFields = ['chainId', 'memo', 'splits', 'unitType'];
const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields];
/**
 * Compares only the fields that MUST be stable across request-hook transforms.
 *
 * This is NOT the primary integrity check — the HMAC binding (Challenge.verify)
 * already covers every challenge field including opaque, digest, and the full
 * serialized request. This function exists as a secondary safety net for the
 * case where the `request()` hook produces credential-dependent output, causing
 * the recomputed challenge to differ from the original in non-economic fields
 * (e.g. `feePayer`). We only need to verify that the economically significant
 * subset hasn't drifted.
 */
function getPinnedChallengeMismatch(expectedChallenge, actualChallenge) {
    for (const field of ['method', 'intent', 'realm']) {
        if (actualChallenge[field] !== expectedChallenge[field])
            return field;
    }
    if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta))
        return 'opaque';
    return getPinnedRequestBindingMismatch(expectedChallenge.request, actualChallenge.request);
}
function getPinnedRequestBindingMismatch(expectedRequest, actualRequest) {
    const expected = getPinnedRequestBinding(expectedRequest);
    const actual = getPinnedRequestBinding(actualRequest);
    return (getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ??
        getMethodBindingMismatch(expected.methodBinding, actual.methodBinding));
}
function getCoreBindingMismatch(expected, actual) {
    return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]));
}
function getMethodBindingMismatch(expected, actual) {
    return methodBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field]));
}
function getPinnedRequestBinding(request) {
    const methodDetails = (request.methodDetails ?? {});
    const amount = normalizeScalar(request.amount ?? methodDetails.amount);
    const chainId = normalizeScalar(request.chainId ?? methodDetails.chainId);
    const currency = normalizeScalar(request.currency ?? methodDetails.currency);
    const memo = normalizeHex(methodDetails.memo);
    const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient);
    const splits = normalizeComparable(methodDetails.splits);
    const unitType = normalizeScalar(request.unitType ?? methodDetails.unitType);
    return {
        coreBinding: {
            ...(amount !== undefined ? { amount } : {}),
            ...(currency !== undefined ? { currency } : {}),
            ...(recipient !== undefined ? { recipient } : {}),
        },
        methodBinding: {
            ...(chainId !== undefined ? { chainId } : {}),
            ...(memo !== undefined ? { memo } : {}),
            ...(splits !== undefined ? { splits } : {}),
            ...(unitType !== undefined ? { unitType } : {}),
        },
    };
}
function normalizeScalar(value) {
    return value === undefined ? undefined : String(value);
}
function normalizeHex(value) {
    if (value === undefined)
        return undefined;
    const normalized = String(value);
    return normalized.startsWith('0x') ? normalized.toLowerCase() : normalized;
}
function normalizeComparable(value) {
    if (value === undefined)
        return undefined;
    if (Array.isArray(value))
        return value.map(normalizeComparable);
    if (value && typeof value === 'object') {
        return Object.fromEntries(Object.entries(value)
            .sort(([left], [right]) => left.localeCompare(right))
            .map(([key, nested]) => [key, normalizeComparable(nested)]));
    }
    return typeof value === 'string' ? normalizeHex(value) : value;
}
function opaqueValuesMatch(expected, actual) {
    return isDeepStrictEqual(expected, actual);
}
function hydrateCredentialMeta(credential) {
    const { challenge } = credential;
    if (challenge.meta !== undefined || challenge.opaque === undefined)
        return credential;
    return {
        ...credential,
        challenge: {
            ...challenge,
            meta: PaymentRequest.deserialize(challenge.opaque),
        },
    };
}
export function compose(...args) {
    // Extract optional html options from last argument
    const last = args[args.length - 1];
    const composeOptions = typeof last === 'object' &&
        last !== null &&
        typeof last !== 'function' &&
        !('_internal' in last)
        ? (() => {
            const opts = last;
            return {
                config: {},
                content: '',
                formatAmount: () => '',
                text: opts.text,
                theme: opts.theme,
            };
        })()
        : undefined;
    const handlers = (composeOptions ? args.slice(0, -1) : args);
    if (handlers.length === 0)
        throw new Error('compose() requires at least one handler');
    return async (input) => {
        // Serve service worker for html-enabled compose
        if (new URL(input.url).searchParams.has(Html.params.serviceWorker)) {
            const hasHtml = handlers.some((h) => h._internal?.html);
            if (hasHtml)
                return {
                    status: 402,
                    challenge: new Response(serviceWorker, {
                        status: 200,
                        headers: {
                            'Content-Type': 'application/javascript',
                            'Cache-Control': 'no-store',
                        },
                    }),
                };
        }
        // Try to extract a Payment credential to decide whether to dispatch or challenge.
        // Only gate on the Payment scheme — other auth schemes (Bearer, Basic, etc.)
        // should fall through to the merged-402 path so all offers are presented.
        const header = input.headers.get('Authorization');
        const paymentHeader = header ? Credential.extractPaymentScheme(header) : null;
        if (paymentHeader) {
            // Parse the credential to find method+intent for dispatch.
            let credential;
            try {
                credential = hydrateCredentialMeta(Credential.deserialize(paymentHeader));
            }
            catch { }
            if (credential) {
                const { method: credMethod, intent: credIntent } = credential.challenge;
                const credReq = credential.challenge.request;
                // Filter by name+intent, then narrow by comparing stable request fields
                // from the echoed challenge against each handler's canonical request.
                // Uses the schema-parsed canonical form (not raw options) so that
                // transformed fields (e.g. amount with decimals) match correctly.
                // Also checks inside methodDetails for fields moved there by transforms.
                const candidates = handlers.filter((h) => {
                    const internal = h._internal;
                    if (!internal || internal.name !== credMethod || internal.intent !== credIntent)
                        return false;
                    const canonical = internal._canonicalRequest;
                    if (!canonical)
                        return true;
                    return (!getPinnedRequestBindingMismatch(canonical, credReq) &&
                        opaqueValuesMatch(internal.meta, credential.challenge.meta));
                });
                const match = candidates[0] ??
                    handlers.find((h) => {
                        const meta = h._internal;
                        return meta?.name === credMethod && meta?.intent === credIntent;
                    });
                if (match)
                    return match(input);
            }
            // Payment credential present but no matching handler — dispatch to first
            // handler which will reject with an appropriate error (invalid challenge, etc.).
            return handlers[0](input);
        }
        // No credential — call all handlers and merge 402 challenges.
        const results = await Promise.all(handlers.map((h) => h(input)));
        const challengeEntries = (() => {
            const entries = [];
            for (let i = 0; i < handlers.length; i++) {
                const result = results[i];
                if (result?.status !== 402)
                    continue;
                const response = result.challenge;
                const wwwAuth = response.headers.get('WWW-Authenticate');
                if (!wwwAuth)
                    continue;
                entries.push({
                    handler: handlers[i],
                    challenge: Challenge.deserialize(wwwAuth),
                    result,
                });
            }
            const acceptPayment = input.headers.get('Accept-Payment');
            if (!acceptPayment)
                return entries;
            try {
                const ranked = AcceptPayment.rank(entries.map((entry) => entry.challenge), AcceptPayment.parse(acceptPayment));
                if (ranked.length === 0)
                    return entries;
                const entriesById = new Map(entries.map((entry) => [entry.challenge.id, entry]));
                return ranked.map((challenge) => entriesById.get(challenge.id));
            }
            catch {
                return entries;
            }
        })();
        // Merge WWW-Authenticate headers from all 402 responses.
        const mergedHeaders = new Headers();
        mergedHeaders.set('Cache-Control', 'no-store');
        for (const entry of challengeEntries) {
            const response = entry.result.challenge;
            const wwwAuth = response.headers.get('WWW-Authenticate');
            if (wwwAuth)
                mergedHeaders.append('WWW-Authenticate', wwwAuth);
        }
        // Collect html-enabled handlers and their challenges
        const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
        const wantsHtml = input.headers.get('Accept')?.includes('text/html');
        if (wantsHtml && htmlEntries.length > 0) {
            const { theme, text } = Html.resolveOptions(
            // Use compose-level options or first html-enabled method's config for the page shell
            composeOptions ?? htmlEntries[0]?.handler._internal.html ?? {});
            // Build data map keyed by challenge.id
            const dataMap = {};
            for (let i = 0; i < htmlEntries.length; i++) {
                const entry = htmlEntries[i];
                dataMap[entry.challenge.id] = {
                    label: entry.handler._internal.name,
                    rootId: `${Html.ids.root}-${i}`,
                    formattedAmount: await entry.handler._internal.html.formatAmount(entry.challenge.request),
                    config: entry.handler._internal.html.config,
                    challenge: entry.challenge,
                    text,
                    theme,
                };
            }
            mergedHeaders.set('Content-Type', 'text/html; charset=utf-8');
            const firstData = Object.values(dataMap)[0];
            const body = Html.render({
                entries: htmlEntries.map((entry) => ({
                    challenge: entry.challenge,
                    content: entry.handler._internal.html.content,
                })),
                dataMap,
                formattedAmount: firstData.formattedAmount,
                panels: true,
                text,
                theme,
            });
            return {
                status: 402,
                challenge: new Response(body, { status: 402, headers: mergedHeaders }),
            };
        }
        // Non-HTML fallback: use first handler's body
        let body = null;
        for (const entry of challengeEntries) {
            if (!body) {
                const response = entry.result.challenge;
                const contentType = response.headers.get('Content-Type');
                if (contentType)
                    mergedHeaders.set('Content-Type', contentType);
                body = await response.text();
                break;
            }
        }
        return {
            status: 402,
            challenge: new Response(body, { status: 402, headers: mergedHeaders }),
        };
    };
}
/**
 * Wraps a payment handler to create a Node.js HTTP listener.
 *
 * On 402: writes the challenge response and ends the connection.
 * On 200: sets the Payment-Receipt header; caller should write response body.
 *
 * @example
 * ```ts
 * import * as http from 'node:http'
 * import { Mppx } from 'mppx/server'
 *
 * const payment = Mppx.create({ ... })
 *
 * http.createServer(async (req, res) => {
 *   const result = await Mppx.toNodeListener(
 *     payment.charge({
 *       amount: '1', currency: '...', recipient: '0x...',
 *     }),
 *   )(req, res)
 *   if (result.status === 402) return
 *   res.end('OK')
 * })
 * ```
 */
export function toNodeListener(handler) {
    return async (req, res) => {
        const result = await handler(Request.fromNodeListener(req, res));
        if (result.status === 402) {
            await NodeListener.sendResponse(res, result.challenge);
        }
        else {
            const wrapped = result.withReceipt(new globalThis.Response());
            res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt'));
        }
        return result;
    };
}
//# sourceMappingURL=Mppx.js.map