/**
 * Thin HTTP transport wrapper around Node 18+'s built-in `fetch`. Centralises:
 *   - URL construction for the Bot API
 *   - x-www-form-urlencoded vs multipart/form-data body building
 *   - response parsing into Telegram's `{ ok, result, description, error_code }` envelope
 *   - error mapping into our error hierarchy
 */
import createDebug from "./internal/debug.js";
import { FatalError, ParseError, TelegramError } from "./errors.js";
import { streamToBuffer } from "./utils.js";
const debug = createDebug("node-telegram-bot-api:http");
/** Telegram error code for `Too Many Requests` — the only retryable status. */
const ERROR_CODE_TOO_MANY_REQUESTS = 429;
/** Default number of automatic retries on a `429` response. */
const DEFAULT_MAX_RETRIES_ON_429 = 2;
/** Extra second added to the advertised `retry_after` before sleeping. */
const RETRY_AFTER_BUFFER_SECONDS = 1;
/**
 * Coerce a value into the string form Telegram expects on `application/x-www-form-urlencoded`
 * bodies. Booleans become `"true"`/`"false"`, arrays/objects become JSON, undefined/null are skipped.
 */
function toStringValue(value) {
    if (value === undefined || value === null)
        return undefined;
    if (typeof value === "string")
        return value;
    if (typeof value === "number" || typeof value === "boolean")
        return String(value);
    return JSON.stringify(value);
}
function appendForm(target, fields) {
    if (!fields)
        return;
    for (const [key, value] of Object.entries(fields)) {
        const stringified = toStringValue(value);
        if (stringified !== undefined)
            target.append(key, stringified);
    }
}
async function buildBody(opts) {
    if (opts.formData && Object.keys(opts.formData).length > 0) {
        // Materialise streams ahead of time so undici/fetch can hand them to FormData.
        for (const file of Object.values(opts.formData)) {
            if (!Buffer.isBuffer(file.value) &&
                typeof file.value.pipe === "function") {
                file.value = await streamToBuffer(file.value);
            }
        }
        const fd = new FormData();
        appendForm(fd, opts.form);
        for (const [name, file] of Object.entries(opts.formData)) {
            const buffer = file.value;
            // Buffer is a Uint8Array view, already a valid BlobPart - no copy needed.
            // The cast narrows ArrayBufferLike -> ArrayBuffer (Node Buffers are never
            // SharedArrayBuffer-backed) to satisfy the stricter DOM BlobPart typing.
            const blob = new Blob([buffer], { type: file.contentType });
            fd.append(name, blob, file.filename);
        }
        // Content-type is set by fetch automatically for FormData.
        return { body: fd };
    }
    const params = new URLSearchParams();
    appendForm(params, opts.form);
    return { body: params.toString(), contentType: "application/x-www-form-urlencoded" };
}
export class HttpClient {
    token;
    options;
    constructor(token, options = {}) {
        this.token = token;
        this.options = options;
    }
    buildURL(method) {
        const base = this.options.baseApiUrl ?? "https://api.telegram.org";
        const tail = this.options.testEnvironment ? "/test" : "";
        return `${base}/bot${this.token}${tail}/${method}`;
    }
    async request(method, opts = {}) {
        if (!this.token)
            throw new FatalError("Telegram Bot Token not provided!");
        const url = this.buildURL(method);
        const { body, contentType } = await buildBody(opts);
        const headers = {
            ...(this.options.request?.headers ?? {}),
        };
        if (contentType)
            headers["content-type"] = contentType;
        debug("HTTP POST %s form=%j", url, opts.form);
        const timeoutMs = opts.timeoutMs ?? this.options.request?.timeoutMs;
        const maxRetries = this.options.request?.maxRetriesOn429 ?? DEFAULT_MAX_RETRIES_ON_429;
        for (let attempt = 0;; attempt++) {
            const { status, parsed } = await this._attempt(url, body, headers, timeoutMs, opts.signal);
            if (parsed.ok)
                return parsed.result;
            // Retry only on 429, sleeping for the advertised `retry_after`.
            const retryAfter = parsed.parameters?.retry_after;
            const canRetry = parsed.error_code === ERROR_CODE_TOO_MANY_REQUESTS &&
                typeof retryAfter === "number" &&
                attempt < maxRetries &&
                !opts.signal?.aborted;
            if (canRetry) {
                const delayMs = (retryAfter + RETRY_AFTER_BUFFER_SECONDS) * 1000;
                debug("429 Too Many Requests, sleeping %dms then retrying (attempt %d/%d)", delayMs, attempt + 1, maxRetries);
                await sleep(delayMs);
                continue;
            }
            throw new TelegramError(`${parsed.error_code ?? status} ${parsed.description ?? "Unknown error"}`, makeResponseInfo(status, parsed));
        }
    }
    /**
     * Perform a single HTTP attempt: fire the request (honoring the per-call
     * timeout and abort signal) and parse Telegram's envelope. Throws
     * {@link FatalError} on a transport failure and {@link ParseError} on an
     * unparseable body; otherwise returns the HTTP status and parsed envelope
     * (ok or not) for the caller's retry/throw decision.
     */
    async _attempt(url, body, headers, timeoutMs, signal) {
        const controller = new AbortController();
        let onExternalAbort = null;
        if (signal) {
            if (signal.aborted)
                controller.abort(signal.reason);
            else {
                onExternalAbort = () => controller.abort(signal.reason);
                signal.addEventListener("abort", onExternalAbort, { once: true });
            }
        }
        const timer = timeoutMs ? setTimeout(() => controller.abort(new Error("HTTP timeout")), timeoutMs) : null;
        // Per-instance fetch seam: a caller-supplied `fetch` (e.g. undici bound to a
        // ProxyAgent) replaces the global; `fetchOptions` are merged in first so the
        // controlled fields below always win and a caller cannot clobber the
        // method/body/headers/abort signal.
        const fetchImpl = this.options.request?.fetch ?? fetch;
        const extraInit = this.options.request?.fetchOptions;
        let response;
        try {
            response = await fetchImpl(url, {
                ...extraInit,
                method: "POST",
                body: body,
                headers,
                signal: controller.signal,
            });
        }
        catch (err) {
            throw new FatalError(err);
        }
        finally {
            if (timer)
                clearTimeout(timer);
            if (signal && onExternalAbort)
                signal.removeEventListener("abort", onExternalAbort);
        }
        const status = response.status;
        const text = await response.text();
        debug("response %s %s", status, text.length > 1000 ? `${text.slice(0, 1000)}…` : text);
        try {
            return { status, parsed: JSON.parse(text) };
        }
        catch {
            throw new ParseError(`Error parsing response: ${text}`, makeResponseInfo(status, text));
        }
    }
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeResponseInfo(status, body) {
    return { status, body };
}
//# sourceMappingURL=http.js.map