/*
  Сервис интеграции ПП Парус 8 с WEB API    
  Модуль ядра: работа с HTTP/HTTPS запросами
*/

//------------------------------
// Подключение внешних библиотек
//------------------------------

const http = require("http"); //Встроенная поддержка HTTP
const https = require("https"); //Встроенная поддержка HTTPS
const { URL } = require("url"); //Обслуживание типовых URL
const { Socket } = require("net"); //Встроенная поддержка сокетов

//-------------------------
// Локальные идентификаторы
//-------------------------

//Таймаут по умолчанию
const DEFAULT_TIMEOUT = 30000;

//Правила получения значений для некомбинируемых заголовков
const NON_COMBINABLE_HEADERS = [
    {
        headerName: "set-cookie",
        getValues: responseHeaders => (typeof responseHeaders?.getSetCookie === "function" ? responseHeaders.getSetCookie() : [])
    }
];

//Ошибка HTTP-запроса
class HttpError extends Error {
    constructor(message, response) {
        super(message);
        this.name = "HttpError";
        this.response = response;
    }
}

//------------------------
// Вспомогательные функции
//------------------------

//Нормализация параметров запроса (для совместимости с ранее применяемыми библиотеками "Request" и "Request-Promice")
const normalizeOptions = options => {
    //Проверим, что нам предлагают обработать объект
    if (!options || typeof options !== "object") {
        throw new TypeError("Параметры HTTP-запроса должны быть объектом");
    }
    //Поддержка параметра uri (как в request-promise)
    const url = options.uri || options.url;
    if (!url) throw new Error("options.url или options.uri обязателен");
    //Обработаем параметр "JSON"
    const hasJsonOption = Object.prototype.hasOwnProperty.call(options, "json");
    const jsonOption = options.json;
    let body = options.body;
    let jsonRequest = false;
    let json = false;
    if (hasJsonOption) {
        if (jsonOption === true) {
            json = true;
        } else if (jsonOption && typeof jsonOption === "object") {
            if (body === undefined) {
                body = jsonOption;
                jsonRequest = true;
            }
            json = true;
        }
    }
    //Определяем HTTP-метод
    let method = options.method;
    if (!method) {
        const hasRequestBody = body != null;
        method = hasRequestBody ? "POST" : "GET";
    }
    //Собираем все уже отконвертированные параметры вместе + конвертируем то, что не требует выноса логики
    return {
        method: String(method).toUpperCase(),
        url,
        headers: options.headers || {},
        query: options.query || options.qs || {},
        body,
        json,
        jsonRequest,
        timeout: options.timeout ?? DEFAULT_TIMEOUT,
        followRedirects: options.followRedirects ?? true,
        proxy: options.proxy || null,
        ca: options.ca,
        cert: options.cert,
        key: options.key,
        passphrase: options.passphrase,
        rejectUnauthorized: options.rejectUnauthorized !== undefined ? options.rejectUnauthorized : true,
        throwOnErrorStatus: options.throwOnErrorStatus ?? options.simple !== false,
        signal: options.signal || null,
        resolveWithFullResponse: options.resolveWithFullResponse || false,
        encoding: options.encoding || null
    };
};

//Формирование ссылки
const buildURL = (base, query = {}) => {
    const url = new URL(base);
    Object.entries(query).forEach(([key, value]) => {
        if (value === undefined || value === null) return;
        url.searchParams.append(key, String(value));
    });
    return url;
};

//Подготовка заголовков запроса
const prepareHeaders = (inputHeaders = {}) => {
    const headers = new Map();
    Object.entries(inputHeaders).forEach(([key, value]) => {
        if (value === undefined || value === null) return;
        headers.set(String(key).toLowerCase(), String(value));
    });
    return headers;
};

//Подготовка тела запроса
const prepareBody = ({ body, jsonRequest, headers }) => {
    //Тела нет
    if (body === undefined || body === null) {
        return { body: undefined, contentLength: undefined, isStream: false };
    }
    //Явно указано, что тело нужно сериализовать как JSON
    if (jsonRequest) {
        const payload = Buffer.from(JSON.stringify(body));
        if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
        return { body: payload, contentLength: payload.length, isStream: false };
    }
    //Тело - бинарные данные
    if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
        return { body, contentLength: body.length, isStream: false };
    }
    //Тело - строка
    if (typeof body === "string") {
        const payload = Buffer.from(body);
        return { body: payload, contentLength: payload.length, isStream: false };
    }
    //Тело - поток
    if (typeof body.pipe === "function") {
        return { body, contentLength: undefined, isStream: true };
    }
    //По умолчанию объектное тело сериализуем в JSON
    if (typeof body === "object") {
        const payload = Buffer.from(JSON.stringify(body));
        if (!headers.has("content-type")) headers.set("content-type", "application/json; charset=utf-8");
        return { body: payload, contentLength: payload.length, isStream: false };
    }
    //Фолбэк для прочих типов
    const payload = Buffer.from(String(body));
    return { body: payload, contentLength: payload.length, isStream: false };
};

//Добавление значения в коллекцию
const appendRepeatedValue = (target, key, value) => {
    if (value === undefined) return;
    if (Object.prototype.hasOwnProperty.call(target, key)) {
        const currentValue = target[key];
        if (Array.isArray(currentValue)) {
            currentValue.push(value);
        } else {
            target[key] = [currentValue, value];
        }
        return;
    }
    target[key] = value;
};

//Удаление заголовка из массива сырых заголовков
const removeHeaderFromRawPairs = (rawHeaders, headerName) => {
    for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
        if (String(rawHeaders[i]).toLowerCase() === headerName) {
            rawHeaders.splice(i, 2);
            i -= 2;
        }
    }
};

//Переопределение значений заголовка
const applyHeaderValues = (headers, rawHeaders, headerName, values) => {
    if (!Array.isArray(values) || values.length === 0) return;
    headers[headerName] = values.slice();
    removeHeaderFromRawPairs(rawHeaders, headerName);
    for (const value of values) {
        rawHeaders.push(headerName, String(value));
    }
};

//Применение правил обработки некомбинируемых заголовков fetch-ответа
const applyNonCombinableFetchHeaders = (headers, rawHeaders, responseHeaders) => {
    for (const rule of NON_COMBINABLE_HEADERS) {
        if (!rule || typeof rule !== "object") continue;
        const headerName = String(rule.headerName || "")
            .trim()
            .toLowerCase();
        if (!headerName || typeof rule.getValues !== "function") continue;
        const values = rule.getValues(responseHeaders);
        applyHeaderValues(headers, rawHeaders, headerName, values);
    }
};

//Формирование заголовков из массива rawHeaders/rawTrailers
const buildHeadersFromRawPairs = rawPairs => {
    const headers = {};
    const rawHeaders = Array.isArray(rawPairs) ? rawPairs.slice() : [];
    for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
        const headerName = String(rawHeaders[i] || "").trim();
        const headerValue = String(rawHeaders[i + 1] || "").trim();
        if (!headerName) continue;
        appendRepeatedValue(headers, headerName.toLowerCase(), headerValue);
    }
    return {
        headers,
        rawHeaders
    };
};

//Формирование заголовков из fetch Headers
const buildHeadersFromFetchHeaders = responseHeaders => {
    const headers = {};
    const rawHeaders = [];
    for (const [name, value] of responseHeaders.entries()) {
        const key = String(name || "")
            .trim()
            .toLowerCase();
        if (!key) continue;
        appendRepeatedValue(headers, key, value);
        rawHeaders.push(name, value);
    }
    //Обработка некомбинируемых заголовков
    applyNonCombinableFetchHeaders(headers, rawHeaders, responseHeaders);
    return {
        headers,
        rawHeaders
    };
};

//Парсинг стартовой строки и заголовков HTTP ответа
const parseHttpHeaderSection = headerText => {
    const lines = headerText.split("\r\n");
    const statusLine = lines[0] || "";
    const statusMatch = statusLine.match(/HTTP\/[\d.]+\s+(\d+)\s*(.*)$/);
    const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : 200;
    const statusMessage = statusMatch ? statusMatch[2] || "" : "";
    const rawHeaderPairs = [];
    for (let i = 1; i < lines.length; i++) {
        const line = lines[i];
        const colonIndex = line.indexOf(":");
        if (colonIndex === -1) continue;
        const name = line.substring(0, colonIndex).trim();
        const value = line.substring(colonIndex + 1).trim();
        if (!name) continue;
        rawHeaderPairs.push(name, value);
    }
    const { headers, rawHeaders } = buildHeadersFromRawPairs(rawHeaderPairs);
    return {
        statusCode,
        statusMessage,
        headers,
        rawHeaders
    };
};

//Обработка ответа (парсинг JSON, форматирование для совместимости с "Request" и "Request-Promice")
const processResponse = (result, options) => {
    //Буфер для обработки тела
    let processedBody = result.body;
    //Если запрошен автоматический парсинг JSON
    if (options.json === true && processedBody) {
        try {
            const text = processedBody.toString(options.encoding || "utf8");
            processedBody = text ? JSON.parse(text) : null;
        } catch (e) {
            //Если не удалось распарсить JSON, возвращаем как есть
            processedBody = processedBody.toString(options.encoding || "utf8");
        }
    } else if (options.encoding && processedBody) {
        //Если указана кодировка, конвертируем в строку
        processedBody = processedBody.toString(options.encoding);
    }
    //Формируем результат в зависимости от "resolveWithFullResponse" (это параметр из "Request": true - отвечать с заголовком, false - отвечать сразу телом)
    if (options.resolveWithFullResponse) {
        //Просили ответить полным ответом
        return {
            ...result,
            statusCode: result.statusCode,
            statusMessage: result.statusMessage || "",
            headers: result.headers,
            body: processedBody,
            url: result.url
        };
    } else {
        //Просили только тело
        return processedBody;
    }
};

//Выполнение HTTP/HTTPS запроса через встроенные модули NodeJS (для прокси и TLS)
const httpRequestNative = (options, url, headers, body) => {
    //Возвращаем промис - запрос исполняется асинхронно
    return new Promise((resolve, reject) => {
        //Определяемся с адресом
        const urlObj = new URL(url);
        //И модулем, обслуживающим протокол
        const isHttps = urlObj.protocol === "https:";
        const httpModule = isHttps ? https : http;
        //Настройки TLS
        const tlsOptions = {};
        if (options.ca) {
            tlsOptions.ca = Array.isArray(options.ca) ? options.ca : [options.ca];
        }
        if (options.cert) {
            tlsOptions.cert = options.cert;
        }
        if (options.key) {
            tlsOptions.key = options.key;
        }
        if (options.passphrase) {
            tlsOptions.passphrase = options.passphrase;
        }
        if (options.rejectUnauthorized !== undefined) {
            tlsOptions.rejectUnauthorized = options.rejectUnauthorized;
        }
        //Настройки прокси
        let proxyUrl = null;
        let proxyAuth = null;
        if (options.proxy) {
            try {
                proxyUrl = new URL(options.proxy);
                if (proxyUrl.username || proxyUrl.password) {
                    proxyAuth = `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username || "")}:${decodeURIComponent(proxyUrl.password || "")}`).toString("base64")}`;
                }
            } catch (e) {
                return reject(new Error(`Некорректный URL прокси: ${e.message}`));
            }
        }
        //Параметры запроса
        const requestOptions = {
            hostname: proxyUrl ? proxyUrl.hostname : urlObj.hostname,
            port: proxyUrl ? proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80) : urlObj.port || (isHttps ? 443 : 80),
            path: proxyUrl ? url : urlObj.pathname + urlObj.search,
            method: options.method,
            headers: Object.fromEntries(headers),
            timeout: options.timeout || DEFAULT_TIMEOUT
        };
        //Добавляем авторизацию прокси в HTTP-заголовки
        if (proxyAuth) {
            requestOptions.headers["Proxy-Authorization"] = proxyAuth;
        }
        //Для HTTPS добавляем TLS опции
        if (isHttps && Object.keys(tlsOptions).length > 0) {
            Object.assign(requestOptions, tlsOptions);
        }
        //Если используется прокси, нужно использовать CONNECT метод для HTTPS
        if (proxyUrl && isHttps) {
            //Для HTTPS через прокси используем туннелирование
            return httpRequestThroughProxy(requestOptions, urlObj, headers, body, proxyUrl, proxyAuth, tlsOptions, options)
                .then(result => resolve(processResponse(result, options)))
                .catch(reject);
        }
        //Обычный запрос (с прокси для HTTP или без прокси)
        const req = httpModule.request(requestOptions, res => {
            //Буфер для данных ответа
            const chunks = [];
            //При получении ответа
            res.on("data", chunk => {
                //Наполняем буфер данными от удаленного сервера
                chunks.push(chunk);
            });
            //При получении признака завершения обмена
            res.on("end", () => {
                //Собираем тело как бинарный буфер
                const responseBody = Buffer.concat(chunks);
                const { headers: normalizedHeaders, rawHeaders } = buildHeadersFromRawPairs(res.rawHeaders);
                const { headers: normalizedTrailers, rawHeaders: rawTrailers } = buildHeadersFromRawPairs(res.rawTrailers);
                //Формируем объект ответа в стиле "Request" для совметимости
                const result = {
                    statusCode: res.statusCode,
                    statusMessage: res.statusMessage || "",
                    headers: normalizedHeaders,
                    rawHeaders,
                    trailers: normalizedTrailers,
                    rawTrailers,
                    httpVersion: res.httpVersion,
                    body: responseBody,
                    ok: res.statusCode >= 200 && res.statusCode < 300,
                    url: url
                };
                //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
                if (options.throwOnErrorStatus && !result.ok) {
                    const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
                    const httpError = new Error(error.message);
                    httpError.response = result;
                    httpError.statusCode = result.statusCode;
                    return reject(httpError);
                }
                //Разрешаем промис с данными ответа
                resolve(processResponse(result, options));
            });
        });
        //При получении сетевых ошибок
        req.on("error", err => {
            const error = new Error(err.message);
            error.code = err.code;
            error.error = { code: err.code };
            reject(error);
        });
        //При нарушении времени, выделенного на выполнения запроса
        req.on("timeout", () => {
            req.destroy();
            const timeoutError = new Error(`Время ожидания выполнения запроса истекло после ${options.timeout || DEFAULT_TIMEOUT} мс`);
            timeoutError.code = "ETIMEDOUT";
            timeoutError.error = { code: "ETIMEDOUT" };
            reject(timeoutError);
        });
        //Отправляем тело запроса
        if (body) {
            if (Buffer.isBuffer(body)) {
                req.write(body);
            } else if (typeof body === "string") {
                req.write(Buffer.from(body));
            } else {
                req.write(Buffer.from(JSON.stringify(body)));
            }
        }
        //Закрываем запрос и начинаем ждать ответ
        req.end();
    });
};

//Выполнение HTTPS запроса через HTTP прокси (туннелирование)
const httpRequestThroughProxy = (requestOptions, targetUrl, headers, body, proxyUrl, proxyAuth, tlsOptions, options) => {
    //Выставим предельное время исполнения запроса
    const timeout = options.timeout || DEFAULT_TIMEOUT;
    //Запрос всё ещё асинхронный - возвращаем промис
    return new Promise((resolve, reject) => {
        //Начальная точка туннеля
        const proxyHost = proxyUrl.hostname;
        const proxyPort = parseInt(proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80), 10);
        //Конечная точка туннеля
        const targetHost = targetUrl.hostname;
        const targetPort = parseInt(targetUrl.port || 443, 10);
        //Создаем соединение с прокси и флаги для отслеживания его состояния (подключено, запрос отправлен)
        const proxySocket = new Socket();
        let connected = false;
        let requestSent = false;
        //Взводим таймер ожидания подключения для подчистки сокета, чтобы не было утечек памяти
        const connectTimeout = setTimeout(() => {
            if (!connected || !requestSent) {
                proxySocket.destroy();
                reject(new Error(`Время ожидания подключения к прокси истекло после ${timeout} мс`));
            }
        }, timeout);
        //Настраиваем встроенный таймаут сокета
        proxySocket.setTimeout(timeout);
        proxySocket.on("timeout", () => {
            proxySocket.destroy();
            reject(new Error(`Время ожидания выполнения запроса истекло после ${timeout} мс`));
        });
        //При подключении
        proxySocket.connect(proxyPort, proxyHost, () => {
            //Поднимаем флаг подключенности
            connected = true;
            //Настраиваем CONNECT-запрос
            const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n`;
            const authHeader = proxyAuth ? `Proxy-Authorization: ${proxyAuth}\r\n` : "";
            const connectHeaders = connectRequest + authHeader + "\r\n";
            //Отправляем CONNECT-запрос
            proxySocket.write(connectHeaders);
            //Буфер для данных ответа на CONNECT-запрос
            let connectResponse = "";
            //Функция для наполнения буфера ответа на CONNECT-запрос и его обработки
            const onConnectData = chunk => {
                //Добавляем в буфер очередную порцию данных, полученных от удаленного сервера
                connectResponse += chunk.toString();
                //Вычислим значение флага завершения заголовка и начала тела ответа (в HTTP они отделяются последовательностью "\r\n\r\n")
                const headerEnd = connectResponse.indexOf("\r\n\r\n");
                //Если был достигнут конец заголовка
                if (headerEnd !== -1) {
                    //Прекращаем прием данных ответа на CONNECT-запрос
                    proxySocket.removeListener("data", onConnectData);
                    clearTimeout(connectTimeout);
                    //Проверяем ответ на CONNECT-запрос
                    const statusLine = connectResponse.substring(0, connectResponse.indexOf("\r\n"));
                    if (statusLine.includes("200")) {
                        //Успешное подключение, создаем TLS соединение
                        const tls = require("tls");
                        const secureSocket = tls.connect(
                            {
                                socket: proxySocket,
                                host: targetHost,
                                servername: targetHost,
                                ...tlsOptions
                            },
                            () => {
                                //Поднимаем флаг отправки запроса
                                requestSent = true;
                                clearTimeout(connectTimeout);
                                //Подготавливаем HTTP-запрос
                                const path = targetUrl.pathname + targetUrl.search;
                                const requestLine = `${requestOptions.method} ${path} HTTP/1.1\r\n`;
                                const hostHeader = `Host: ${targetHost}${targetPort !== 443 ? `:${targetPort}` : ""}\r\n`;
                                const requestHeaders = Array.from(headers.entries())
                                    .map(([key, value]) => `${key}: ${value}\r\n`)
                                    .join("");
                                const httpRequest = requestLine + hostHeader + requestHeaders + "\r\n";
                                //Отправляем HTTP-запрос через TLS
                                secureSocket.write(httpRequest);
                                if (body) {
                                    const bodyBuffer = Buffer.isBuffer(body)
                                        ? body
                                        : typeof body === "string"
                                          ? Buffer.from(body)
                                          : Buffer.from(JSON.stringify(body));
                                    secureSocket.write(bodyBuffer);
                                }
                                //Готовим буфер для чтения ответа
                                let responseBuffer = Buffer.alloc(0);
                                let responseHeaders = {};
                                let responseRawHeaders = [];
                                let statusCode = 200;
                                let statusMessage = "";
                                let headerParsed = false;
                                let contentLength = -1;
                                //Функция для наполнения и обработки буфера ответа
                                const onSecureData = chunk => {
                                    //Добавляем очередную порцию к данным буфера ответа
                                    responseBuffer = Buffer.concat([responseBuffer, chunk]);
                                    //Если ещё не поднят флаг того, что разобрали заголовок
                                    if (!headerParsed) {
                                        //Найдем в буфере метку конца заголовка
                                        const headerEnd = responseBuffer.indexOf("\r\n\r\n");
                                        //Метка конца заголовка найдена
                                        if (headerEnd !== -1) {
                                            //Фиксируем текст заголовка от начала буфера до отметки конца заголовка
                                            const headerText = responseBuffer.subarray(0, headerEnd).toString();
                                            //Остальное - будет телом
                                            const bodyStart = headerEnd + 4;
                                            responseBuffer = responseBuffer.subarray(bodyStart);
                                            //Разбираем полученный текст заголовка
                                            const parsedHeader = parseHttpHeaderSection(headerText);
                                            statusCode = parsedHeader.statusCode;
                                            statusMessage = parsedHeader.statusMessage;
                                            responseHeaders = parsedHeader.headers;
                                            responseRawHeaders = parsedHeader.rawHeaders;

                                            const contentLengthHeader = responseHeaders["content-length"];
                                            const contentLengthValue = Array.isArray(contentLengthHeader)
                                                ? contentLengthHeader[contentLengthHeader.length - 1]
                                                : contentLengthHeader;
                                            if (contentLengthValue !== undefined) {
                                                contentLength = parseInt(contentLengthValue, 10);
                                            }
                                            //И поднимаем флаг его разобранности
                                            headerParsed = true;
                                            //Если есть Content-Length и мы уже получили все данные
                                            if (contentLength >= 0 && responseBuffer.length >= contentLength) {
                                                //Прекращаем прием данных в буфер
                                                secureSocket.removeListener("data", onSecureData);
                                                //Закрываем TCP-соединение
                                                secureSocket.end();
                                            }
                                        }
                                    } else {
                                        //Заголовок уже прочитан, но данные ещё могут приходить - проверяем, получили ли мы все данные
                                        if (contentLength >= 0 && responseBuffer.length >= contentLength) {
                                            //Прекращаем прием данных в буфер
                                            secureSocket.removeListener("data", onSecureData);
                                            //Закрываем TCP-соединение
                                            secureSocket.end();
                                        } else if (contentLength < 0) {
                                            //Нет Content-Length, читаем до конца
                                            //Будем ждать закрытия соединения со стороны хоста
                                        }
                                    }
                                };
                                //Подключаем определенную выше функцию обработки данных к событию их получения
                                secureSocket.on("data", onSecureData);
                                //Слушаем завершение соединения и реагируем на него
                                secureSocket.on("end", () => {
                                    //Формируем объект с результатами обработки соединения, совместимый с ожиданиями сервера приложений
                                    const result = {
                                        statusCode: statusCode,
                                        statusMessage: statusMessage,
                                        headers: responseHeaders,
                                        rawHeaders: responseRawHeaders,
                                        trailers: {},
                                        rawTrailers: [],
                                        httpVersion: "1.1",
                                        body: responseBuffer,
                                        ok: statusCode >= 200 && statusCode < 300,
                                        url: targetUrl.toString()
                                    };
                                    //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
                                    if (options.throwOnErrorStatus && !result.ok) {
                                        const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
                                        const httpError = new Error(error.message);
                                        httpError.response = result;
                                        httpError.statusCode = result.statusCode;
                                        return reject(httpError);
                                    }
                                    //Разрешаем промис предварительно сформировав ответ, совместимый со старыми библиотекаим "Request"
                                    resolve(processResponse(result, options));
                                });
                            }
                        );
                        //Слушаем сетевые ошибки на защищенном соединении
                        secureSocket.on("error", err => reject(err));
                    } else {
                        //Ответ на CONNECT-запрос содержит статус, отличный от 200 - отключаемся, не прошли прокси
                        proxySocket.destroy();
                        reject(new Error(`Прокси вернул ошибку: ${statusLine}`));
                    }
                }
            };
            //Подключаем функцию обработки данных CONNECT-запроса и слушаем их
            proxySocket.on("data", onConnectData);
        });
        //Слушаем сетевые ошибки при выполнении CONNECT-запроса
        proxySocket.on("error", err => {
            clearTimeout(connectTimeout);
            reject(err);
        });
    });
};

//------------
// Тело модуля
//------------

//Выполнение HTTP/HTTPS запроса
const httpRequest = async (rawOptions = {}) => {
    try {
        //Нормализуем параметры запроса
        const options = normalizeOptions(rawOptions);
        //Сформируем ссылку
        const url = buildURL(options.url, options.query);
        //Подготовим заголовки и тело запроса
        const headers = prepareHeaders(options.headers);
        const { body, contentLength, isStream } = prepareBody({
            body: options.body,
            jsonRequest: options.jsonRequest,
            headers
        });
        //Если не указан размер тела
        if (contentLength !== undefined && !headers.has("content-length")) {
            //Установим размер тела в заголовок
            headers.set("content-length", String(contentLength));
        }
        //Если есть поток, читаем его
        let requestBody = body;
        if (isStream && body && typeof body.pipe === "function") {
            const chunks = [];
            try {
                for await (const chunk of body) {
                    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
                }
                requestBody = chunks.length > 0 ? Buffer.concat(chunks) : undefined;
            } catch (streamError) {
                throw new Error(`Ошибка чтения потока: ${streamError.message}`);
            }
        }
        //Если нужен прокси или специальные TLS настройки, используем встроенные модули
        if (options.proxy || options.ca || options.cert || options.key || options.passphrase || options.rejectUnauthorized === false) {
            return await httpRequestNative(options, url.toString(), headers, requestBody);
        }
        //Для простых запросов используем встроенный fetch
        const fetchImpl = globalThis.fetch;
        if (typeof fetchImpl !== "function") {
            throw new Error('Среда исполнения не поддерживает "fetch", используйте NodeJS >= 22.21.1.');
        }
        //Добавим к нему аборт-контроллер для управления отменой запроса по таймауту
        const timeoutController = new AbortController();
        const signals = [];
        //Один - на базе параметра "nTimeoutAsynch", получаемого из "Функции сервиса обмена", атрибут "Таймаут асинхронной отправки" (им управляет таймер, взводимый в обёртке "wrapPromiseTimeout" и передаваемый сюда в качестве сигнала)
        if (options.signal && options.signal instanceof AbortSignal) {
            signals.push(options.signal);
        }
        //Второй - на базе параметра "nTimeoutConn", получаемого из "Функции сервиса обмена", атрибут "Таймаут сетевого подключения"
        signals.push(timeoutController.signal);
        const combinedSignal = AbortSignal.any(signals);
        //Взводим таймер для "второго" сигнала (по атрибуту "Таймаут сетевого подключения")
        const timeoutId =
            options.timeout > 0
                ? setTimeout(
                      () => timeoutController.abort(new Error(`Время ожидания выполнения запроса истекло после ${options.timeout} мс`)),
                      options.timeout
                  )
                : null;
        //Выполняем запрос
        try {
            //Соберем параметры запроса в формате сервиса интеграции в формат, ожидаемый "fetch"
            const fetchOptions = {
                method: options.method,
                headers: Object.fromEntries(headers),
                body: requestBody,
                signal: combinedSignal,
                redirect: options.followRedirects ? "follow" : "manual"
            };
            //Непосредственно запрос и чтение ответа
            const response = await fetchImpl(url.toString(), fetchOptions);
            //Читаем тело ответа
            const responseBody = Buffer.from(await response.arrayBuffer());
            const { headers: normalizedHeaders, rawHeaders } = buildHeadersFromFetchHeaders(response.headers);
            //Собираем ответ в формате, совместимом с сервисом интеграции
            const result = {
                statusCode: response.status,
                statusMessage: response.statusText || "",
                headers: normalizedHeaders,
                rawHeaders,
                trailers: {},
                rawTrailers: [],
                httpVersion: "1.1",
                body: responseBody,
                ok: response.ok,
                url: response.url
            };
            //Если установлена опция "Выдавать ошибку для HTTP-статуса != 200" - будем отклонять промис с ней
            if (options.throwOnErrorStatus && !result.ok) {
                const error = new HttpError(`Запрос не выполнен со статусом ${result.statusCode}`, result);
                const httpError = new Error(error.message);
                httpError.response = result;
                httpError.statusCode = result.statusCode;
                throw httpError;
            }
            //Возвращаем ответ, предварительно обеспечив совместимомть со старыми библиотекаим "Request"
            return processResponse(result, options);
        } finally {
            //Подчистка таймера - успели всё сделать до его истечения
            if (timeoutId) clearTimeout(timeoutId);
        }
    } catch (e) {
        //Преобразуем ошибки для совместимости с request-promise
        if (e.name === "AbortError" || e.message.includes("время ожидания")) {
            const timeoutError = new Error(e.message);
            timeoutError.code = "ETIMEDOUT";
            timeoutError.error = { code: "ETIMEDOUT" };
            throw timeoutError;
        }
        //Если ошибка уже имеет response (из httpRequestNative или fetch), пробрасываем как есть
        if (e.response) {
            throw e;
        }
        //Полученную от нативных методов (httpRequestNative) "HttpError" преобразуем в обычный объект ошибки с дополнительными полями для совместимости
        if (e instanceof HttpError) {
            const httpError = new Error(e.message);
            httpError.response = e.response;
            httpError.statusCode = e.response.statusCode;
            throw httpError;
        }
        //Для других ошибок добавляем поле error для совместимости
        if (e.code) {
            e.error = { code: e.code };
        }
        //Наконец-то её можно отдать в вызывающее окружение
        throw e;
    }
};

//-----------------
// Интерфейс модуля
//-----------------

exports.httpRequest = httpRequest;
