import { parseUnits } from 'viem';
import * as Challenge from '../../Challenge.js';
import * as Fetch from '../../client/internal/Fetch.js';
import * as PaymentCredential from '../../Credential.js';
import { deserializeSessionReceipt } from '../session/Receipt.js';
import { parseEvent } from '../session/Sse.js';
import * as Ws from '../session/Ws.js';
import { session as sessionPlugin } from './Session.js';
const WebSocketReadyState = {
    CONNECTING: 0,
    OPEN: 1,
    CLOSING: 2,
    CLOSED: 3,
};
// Browser-style WebSocket clients may only initiate close with 1000 or 3000-4999.
// Keep protocol/policy close codes on the server side and use an app-defined code here.
const ClientWebSocketProtocolErrorCloseCode = 3008;
/**
 * Creates a session manager that handles the full client payment lifecycle:
 * channel open, incremental vouchers, SSE streaming, and channel close.
 *
 * Internally delegates to the `session()` method for all
 * channel state management and credential creation, and to `Fetch.from`
 * for the 402 challenge/retry flow.
 *
 * ## Session resumption
 *
 * All channel state is held **in memory**. If the client process restarts,
 * the session is lost and a new on-chain channel will be opened on the next
 * request — the previous channel's deposit is orphaned until manually closed.
 *
 * When the server includes a `channelId` in the 402 challenge `methodDetails`,
 * the client will attempt to recover the channel by reading its on-chain state
 * via `getOnChainChannel()`. If the channel has a positive deposit and is not
 * finalized, it resumes from the on-chain settled amount.
 */
export function sessionManager(parameters) {
    const fetchFn = parameters.fetch ?? globalThis.fetch;
    const WebSocketImpl = parameters.webSocket ??
        globalThis.WebSocket;
    const maxVoucherCumulative = parameters.maxDeposit !== undefined
        ? parseUnits(parameters.maxDeposit, parameters.decimals ?? 6)
        : null;
    let channel = null;
    let lastChallenge = null;
    let lastUrl = null;
    let spent = 0n;
    let activeSocketChallenge = null;
    let activeSocketChannelId = null;
    let activeSocket = null;
    let closeReadyReceipt = null;
    let closeReadyWaiter = null;
    let expectedSocketCloseAmount = null;
    let receiptWaiter = null;
    let wsDeliveredChunks = 0n;
    let wsTickCost = 0n;
    const method = sessionPlugin({
        account: parameters.account,
        authorizedSigner: parameters.authorizedSigner,
        getClient: parameters.client ? () => parameters.client : parameters.getClient,
        escrowContract: parameters.escrowContract,
        decimals: parameters.decimals,
        maxDeposit: parameters.maxDeposit,
        onChannelUpdate(entry) {
            if (entry.channelId !== channel?.channelId)
                spent = 0n;
            channel = entry;
        },
    });
    const wrappedFetch = Fetch.from({
        fetch: fetchFn,
        methods: [method],
        onChallenge: async (challenge, _helpers) => {
            lastChallenge = challenge;
            return undefined;
        },
    });
    function updateSpentFromReceipt(receipt) {
        if (!receipt || receipt.channelId !== channel?.channelId)
            return;
        assertReceiptWithinLocalState(receipt);
        const next = BigInt(receipt.spent);
        spent = spent > next ? spent : next;
    }
    function assertReceiptWithinLocalState(receipt) {
        if (!channel || receipt.channelId !== channel.channelId)
            return;
        const acceptedCumulative = BigInt(receipt.acceptedCumulative);
        const receiptSpent = BigInt(receipt.spent);
        if (receiptSpent > acceptedCumulative) {
            throw new Error('receipt spent exceeds accepted cumulative voucher amount');
        }
        if (acceptedCumulative > channel.cumulativeAmount) {
            throw new Error('receipt accepted cumulative exceeds local voucher state');
        }
        if (receiptSpent > channel.cumulativeAmount) {
            throw new Error('receipt spent exceeds local voucher state');
        }
        assertVoucherWithinLocalLimit(acceptedCumulative);
        assertVoucherWithinLocalLimit(receiptSpent);
    }
    function waitForReceipt(predicate = () => true) {
        if (receiptWaiter)
            throw new Error('receipt wait already in progress');
        return new Promise((resolve, reject) => {
            receiptWaiter = { predicate, resolve, reject };
        });
    }
    function waitForCloseReady() {
        if (closeReadyReceipt)
            return Promise.resolve(closeReadyReceipt);
        if (closeReadyWaiter)
            throw new Error('close-ready wait already in progress');
        return new Promise((resolve, reject) => {
            closeReadyWaiter = { resolve, reject };
        });
    }
    function settleReceipt(receipt) {
        if (!receiptWaiter)
            return;
        if (!receiptWaiter.predicate(receipt))
            return;
        const waiter = receiptWaiter;
        receiptWaiter = null;
        waiter.resolve(receipt);
    }
    function settleCloseReady(receipt) {
        closeReadyReceipt = receipt;
        if (!closeReadyWaiter)
            return;
        const waiter = closeReadyWaiter;
        closeReadyWaiter = null;
        waiter.resolve(receipt);
    }
    function rejectReceipt(error) {
        if (!receiptWaiter)
            return;
        const waiter = receiptWaiter;
        receiptWaiter = null;
        waiter.reject(error);
    }
    function rejectCloseReady(error) {
        if (!closeReadyWaiter)
            return;
        const waiter = closeReadyWaiter;
        closeReadyWaiter = null;
        waiter.reject(error);
    }
    function getFallbackCloseAmount(challenge, channelId) {
        if (closeReadyReceipt &&
            closeReadyReceipt.challengeId === challenge.id &&
            closeReadyReceipt.channelId === channelId) {
            return closeReadyReceipt.spent;
        }
        const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n;
        // For WS sessions, use delivered chunk count × tick cost as a tight spend
        // estimate.  Without this, a socket death before close-ready would cause
        // the client to sign for the full cumulative voucher authorization —
        // potentially orders of magnitude more than what was actually consumed.
        // The estimate may undercount by at most 1 chunk (if the server committed
        // a charge but the socket died before delivering the message).
        if (wsTickCost > 0n) {
            const deliveryEstimate = wsDeliveredChunks * wsTickCost;
            const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate;
            return (bestSpent > cumulative ? cumulative : bestSpent).toString();
        }
        // SSE/HTTP: spent is kept in sync by inline receipts, use it directly.
        return spent.toString();
    }
    function assertVoucherWithinLocalLimit(cumulativeAmount) {
        if (maxVoucherCumulative === null)
            return;
        if (cumulativeAmount <= maxVoucherCumulative)
            return;
        throw new Error(`requested voucher amount ${cumulativeAmount} exceeds local maxDeposit ${maxVoucherCumulative}`);
    }
    function toPaymentResponse(response) {
        const receiptHeader = response.headers.get('Payment-Receipt');
        const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null;
        updateSpentFromReceipt(receipt);
        return Object.assign(response, {
            receipt,
            challenge: lastChallenge,
            channelId: channel?.channelId ?? null,
            cumulative: channel?.cumulativeAmount ?? 0n,
        });
    }
    async function doFetch(input, init) {
        lastUrl = input;
        const response = await wrappedFetch(input, init);
        return toPaymentResponse(response);
    }
    function createManagedSocket(socket) {
        const listeners = new Map();
        let emittedClose = false;
        let messageBuffer = [];
        let readyState = socket.readyState;
        const add = (type, listener, options) => {
            let set = listeners.get(type);
            if (!set) {
                set = new Set();
                listeners.set(type, set);
            }
            set.add({
                once: typeof options === 'object' ? options.once === true : false,
                value: listener,
            });
            if (type === 'message' && messageBuffer) {
                const buffered = messageBuffer;
                messageBuffer = null;
                for (const event of buffered)
                    emit('message', event);
            }
        };
        const remove = (type, listener) => {
            const set = listeners.get(type);
            if (!set)
                return;
            for (const entry of set) {
                if (entry.value === listener)
                    set.delete(entry);
            }
        };
        const emit = (type, event) => {
            if (type === 'close') {
                if (emittedClose)
                    return;
                emittedClose = true;
                readyState = WebSocketReadyState.CLOSED;
                messageBuffer = null;
            }
            if (type === 'open')
                readyState = WebSocketReadyState.OPEN;
            if (type === 'message' && messageBuffer) {
                messageBuffer.push(event);
                return;
            }
            const property = `on${type}`;
            const handler = managed[property];
            if (typeof handler === 'function')
                handler(event);
            const set = listeners.get(type);
            if (!set)
                return;
            for (const entry of Array.from(set)) {
                if (typeof entry.value === 'function')
                    entry.value(event);
                else
                    entry.value.handleEvent(event);
                if (entry.once)
                    set.delete(entry);
            }
        };
        const managed = {
            addEventListener: add,
            close(code, reason) {
                socket.close(code, reason);
            },
            get bufferedAmount() {
                return socket.bufferedAmount;
            },
            get extensions() {
                return socket.extensions;
            },
            on(type, listener) {
                add(type, listener);
            },
            onclose: null,
            onerror: null,
            _onmessage: null,
            get onmessage() {
                return managed._onmessage;
            },
            set onmessage(fn) {
                managed._onmessage = fn;
                if (fn && messageBuffer) {
                    const buffered = messageBuffer;
                    messageBuffer = null;
                    for (const event of buffered)
                        emit('message', event);
                }
            },
            onopen: null,
            off(type, listener) {
                remove(type, listener);
            },
            get protocol() {
                return socket.protocol;
            },
            get readyState() {
                return readyState;
            },
            removeEventListener: remove,
            send(data) {
                socket.send(data);
            },
            get url() {
                return socket.url;
            },
        };
        return {
            emit,
            socket: managed,
        };
    }
    const self = {
        get channelId() {
            return channel?.channelId;
        },
        get cumulative() {
            return channel?.cumulativeAmount ?? 0n;
        },
        get opened() {
            return channel?.opened ?? false;
        },
        async open(options) {
            if (channel?.opened)
                return;
            if (!lastChallenge) {
                throw new Error('No challenge available. Make a request first to receive a 402 challenge, or pass a challenge via .fetch()/.sse().');
            }
            const deposit = options?.deposit;
            const credential = await method.createCredential({
                challenge: lastChallenge,
                context: {
                    ...(deposit !== undefined && { depositRaw: deposit.toString() }),
                },
            });
            if (!lastUrl)
                throw new Error('No URL available — call fetch() or sse() before open().');
            const response = await fetchFn(lastUrl, {
                method: 'POST',
                headers: { Authorization: credential },
            });
            if (!response.ok) {
                const body = await response.text().catch(() => '');
                const wwwAuth = response.headers.get('WWW-Authenticate') ?? '';
                throw new Error(`Open request failed with status ${response.status}${body ? `: ${body}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`);
            }
        },
        fetch: doFetch,
        async sse(input, init) {
            const { onReceipt, signal, ...fetchInit } = init ?? {};
            const sseInit = {
                ...fetchInit,
                headers: {
                    ...Fetch.normalizeHeaders(fetchInit.headers),
                    Accept: 'text/event-stream',
                },
                ...(signal ? { signal } : {}),
            };
            const response = await doFetch(input, sseInit);
            // Snapshot the challenge at SSE open time so concurrent
            // calls don't overwrite it.
            const sseChallenge = lastChallenge;
            if (!response.body)
                throw new Error('Response has no body.');
            const reader = response.body.getReader();
            const decoder = new TextDecoder();
            async function* iterate() {
                let buffer = '';
                try {
                    while (true) {
                        if (signal?.aborted)
                            break;
                        const { done, value } = await reader.read();
                        if (done)
                            break;
                        buffer += decoder.decode(value, { stream: true });
                        const parts = buffer.split('\n\n');
                        buffer = parts.pop();
                        for (const part of parts) {
                            if (!part.trim())
                                continue;
                            const event = parseEvent(part);
                            if (!event)
                                continue;
                            switch (event.type) {
                                case 'message':
                                    yield event.data;
                                    break;
                                case 'payment-need-voucher': {
                                    if (!channel || !sseChallenge)
                                        break;
                                    const required = BigInt(event.data.requiredCumulative);
                                    assertVoucherWithinLocalLimit(required);
                                    channel.cumulativeAmount =
                                        channel.cumulativeAmount > required ? channel.cumulativeAmount : required;
                                    const credential = await method.createCredential({
                                        challenge: sseChallenge,
                                        context: {
                                            action: 'voucher',
                                            channelId: channel.channelId,
                                            cumulativeAmountRaw: channel.cumulativeAmount.toString(),
                                        },
                                    });
                                    const voucherResponse = await fetchFn(input, {
                                        method: 'POST',
                                        headers: { Authorization: credential },
                                    });
                                    if (!voucherResponse.ok) {
                                        throw new Error(`Voucher POST failed with status ${voucherResponse.status}`);
                                    }
                                    break;
                                }
                                case 'payment-receipt':
                                    updateSpentFromReceipt(event.data);
                                    onReceipt?.(event.data);
                                    break;
                            }
                        }
                    }
                }
                finally {
                    reader.releaseLock();
                }
            }
            return iterate();
        },
        async ws(input, init) {
            if (!WebSocketImpl) {
                throw new Error('No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.');
            }
            const { onReceipt, protocols, signal } = init ?? {};
            const wsUrl = new URL(input.toString());
            const httpUrl = new URL(wsUrl.toString());
            if (httpUrl.protocol === 'ws:')
                httpUrl.protocol = 'http:';
            if (httpUrl.protocol === 'wss:')
                httpUrl.protocol = 'https:';
            lastUrl = httpUrl.toString();
            const probe = await fetchFn(httpUrl, signal ? { signal } : undefined);
            if (probe.status !== 402) {
                throw new Error(`Expected a 402 payment challenge from ${httpUrl}, received ${probe.status} instead.`);
            }
            const challenge = Challenge.fromResponseList(probe).find((item) => item.method === method.name && item.intent === method.intent);
            if (!challenge) {
                throw new Error('No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.');
            }
            lastChallenge = challenge;
            const credential = await method.createCredential({
                challenge: challenge,
                context: {},
            });
            closeReadyReceipt = null;
            activeSocketChallenge = challenge;
            wsDeliveredChunks = 0n;
            wsTickCost = BigInt(challenge.request.amount);
            const openCredential = PaymentCredential.deserialize(credential);
            activeSocketChannelId = openCredential.payload.channelId;
            const rawSocket = new WebSocketImpl(wsUrl, protocols);
            activeSocket = rawSocket;
            const managedSocket = createManagedSocket(rawSocket);
            const failSocketFlow = (message) => {
                rejectReceipt(new Error(message));
                rejectCloseReady(new Error(message));
                if (rawSocket.readyState === WebSocketReadyState.CONNECTING ||
                    rawSocket.readyState === WebSocketReadyState.OPEN) {
                    rawSocket.close(ClientWebSocketProtocolErrorCloseCode, message);
                }
            };
            const isExpectedReceipt = (receipt) => receipt.challengeId === challenge.id && receipt.channelId === activeSocketChannelId;
            const socketOpened = new Promise((resolve, reject) => {
                const onOpen = () => {
                    rawSocket.removeEventListener('error', onError);
                    managedSocket.emit('open', { type: 'open' });
                    resolve();
                };
                const onError = () => {
                    rawSocket.removeEventListener('open', onOpen);
                    reject(new Error(`WebSocket connection to ${wsUrl} failed to open.`));
                };
                rawSocket.addEventListener('open', onOpen, { once: true });
                rawSocket.addEventListener('error', onError, { once: true });
            });
            rawSocket.addEventListener('close', (event) => {
                if (activeSocket === rawSocket)
                    activeSocket = null;
                if (activeSocketChallenge === challenge)
                    activeSocketChallenge = null;
                if (activeSocketChannelId === openCredential.payload.channelId)
                    activeSocketChannelId = null;
                expectedSocketCloseAmount = null;
                rejectReceipt(new Error('WebSocket closed before the payment flow completed.'));
                rejectCloseReady(new Error('WebSocket closed before the payment flow completed.'));
                managedSocket.emit('close', {
                    code: event.code ?? 1000,
                    reason: event.reason ?? '',
                    type: 'close',
                    wasClean: true,
                });
            });
            rawSocket.addEventListener('error', () => {
                managedSocket.emit('error', { type: 'error' });
            });
            rawSocket.addEventListener('message', async (event) => {
                const raw = typeof event.data === 'string' ? event.data : undefined;
                if (!raw)
                    return;
                const message = Ws.parseMessage(raw);
                if (!message) {
                    managedSocket.emit('message', { data: raw, type: 'message' });
                    return;
                }
                switch (message.mpp) {
                    case 'authorization':
                        break;
                    case 'message':
                        wsDeliveredChunks += 1n;
                        managedSocket.emit('message', { data: message.data, type: 'message' });
                        break;
                    case 'payment-close-ready':
                        if (!isExpectedReceipt(message.data)) {
                            failSocketFlow('received mismatched payment-close-ready frame');
                            break;
                        }
                        if (BigInt(message.data.spent) > (channel?.cumulativeAmount ?? 0n)) {
                            failSocketFlow('received payment-close-ready beyond local voucher state');
                            break;
                        }
                        updateSpentFromReceipt(message.data);
                        onReceipt?.(message.data);
                        settleCloseReady(message.data);
                        managedSocket.emit('close', { code: 1000, reason: 'stream complete', type: 'close' });
                        break;
                    case 'payment-error':
                        rejectReceipt(new Error(message.message));
                        rejectCloseReady(new Error(message.message));
                        break;
                    case 'payment-need-voucher': {
                        if (message.data.channelId !== activeSocketChannelId) {
                            failSocketFlow('received mismatched payment-need-voucher frame');
                            break;
                        }
                        const required = BigInt(message.data.requiredCumulative);
                        try {
                            assertVoucherWithinLocalLimit(required);
                        }
                        catch (error) {
                            failSocketFlow(error instanceof Error
                                ? error.message
                                : 'requested voucher amount exceeds local maxDeposit');
                            break;
                        }
                        const nextCumulative = (channel?.cumulativeAmount ?? 0n) > required
                            ? (channel?.cumulativeAmount ?? 0n)
                            : required;
                        if (channel?.channelId === activeSocketChannelId)
                            channel.cumulativeAmount = nextCumulative;
                        const voucher = await method.createCredential({
                            challenge: challenge,
                            context: {
                                action: 'voucher',
                                channelId: activeSocketChannelId,
                                cumulativeAmountRaw: nextCumulative.toString(),
                            },
                        });
                        rawSocket.send(Ws.formatAuthorizationMessage(voucher));
                        break;
                    }
                    case 'payment-receipt':
                        if (!isExpectedReceipt(message.data)) {
                            failSocketFlow('received mismatched payment-receipt frame');
                            break;
                        }
                        if (expectedSocketCloseAmount !== null &&
                            Boolean(message.data.txHash) &&
                            (message.data.acceptedCumulative !== expectedSocketCloseAmount ||
                                message.data.spent !== expectedSocketCloseAmount)) {
                            failSocketFlow('received mismatched payment-close receipt frame');
                            break;
                        }
                        updateSpentFromReceipt(message.data);
                        onReceipt?.(message.data);
                        settleReceipt(message.data);
                        break;
                }
            });
            if (signal) {
                signal.addEventListener('abort', () => {
                    rejectReceipt(new Error('WebSocket payment flow aborted.'));
                    rejectCloseReady(new Error('WebSocket payment flow aborted.'));
                    rawSocket.close();
                }, { once: true });
            }
            await socketOpened;
            rawSocket.send(Ws.formatAuthorizationMessage(credential));
            await waitForReceipt();
            return managedSocket.socket;
        },
        async close() {
            const closeChallenge = activeSocketChallenge ?? lastChallenge;
            const closeChannelId = activeSocketChannelId ?? channel?.channelId;
            if (!channel?.opened)
                return undefined;
            if (!closeChallenge) {
                throw new Error('Cannot close session: no challenge available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or make a request first to receive a fresh 402 challenge.');
            }
            if (!closeChannelId) {
                throw new Error('Cannot close session: no channel ID available. The session may not have been fully opened.');
            }
            if (activeSocket?.readyState === WebSocketReadyState.OPEN) {
                const ready = closeReadyReceipt ??
                    (await (async () => {
                        activeSocket.send(Ws.formatCloseRequestMessage());
                        return waitForCloseReady();
                    })());
                const readySpent = BigInt(ready.spent);
                if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) {
                    throw new Error('close-ready spent exceeds local voucher state');
                }
                const credential = await method.createCredential({
                    challenge: closeChallenge,
                    context: {
                        action: 'close',
                        channelId: closeChannelId,
                        cumulativeAmountRaw: readySpent.toString(),
                    },
                });
                const expectedCloseAmount = readySpent.toString();
                expectedSocketCloseAmount = expectedCloseAmount;
                try {
                    const pendingReceipt = waitForReceipt((receipt) => Boolean(receipt.txHash) &&
                        receipt.challengeId === closeChallenge.id &&
                        receipt.channelId === closeChannelId &&
                        receipt.acceptedCumulative === expectedCloseAmount &&
                        receipt.spent === expectedCloseAmount);
                    activeSocket.send(Ws.formatAuthorizationMessage(credential));
                    const receipt = await pendingReceipt;
                    activeSocket.close();
                    closeReadyReceipt = null;
                    return receipt;
                }
                finally {
                    expectedSocketCloseAmount = null;
                }
            }
            const credential = await method.createCredential({
                challenge: closeChallenge,
                context: {
                    action: 'close',
                    channelId: closeChannelId,
                    cumulativeAmountRaw: (() => {
                        const closeAmount = BigInt(getFallbackCloseAmount(closeChallenge, closeChannelId));
                        if (closeAmount > channel.cumulativeAmount) {
                            throw new Error('fallback close amount exceeds local voucher state');
                        }
                        assertVoucherWithinLocalLimit(closeAmount);
                        return closeAmount.toString();
                    })(),
                },
            });
            if (!lastUrl) {
                throw new Error('Cannot close session: no URL available. This usually means close() was called on a SessionManager instance that was recreated after the session was opened. Use the same SessionManager instance that opened the session, or call fetch()/sse() before close().');
            }
            const response = await fetchFn(lastUrl, {
                method: 'POST',
                headers: { Authorization: credential },
            });
            if (!response.ok) {
                const body = await response.text().catch(() => '');
                const detail = (() => {
                    if (!body)
                        return '';
                    if (!response.headers.get('Content-Type')?.includes('application/problem+json')) {
                        return body;
                    }
                    try {
                        const problem = JSON.parse(body);
                        return problem.detail ?? body;
                    }
                    catch {
                        return body;
                    }
                })();
                const wwwAuth = response.headers.get('WWW-Authenticate') ?? '';
                throw new Error(`Close request failed with status ${response.status}${detail ? `: ${detail}` : ''}${wwwAuth ? ` [WWW-Authenticate: ${wwwAuth}]` : ''}`);
            }
            const receiptHeader = response.headers.get('Payment-Receipt');
            const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : undefined;
            return receipt;
        },
    };
    return self;
}
//# sourceMappingURL=SessionManager.js.map