import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import fs from 'fs';
import { Readable } from 'stream';
import nock from 'nock';
import https from 'https';

import { Provider } from '../../src/resources/provider.js';
import * as HttpErrors from '../../src/httpErrors.js';
import Logger from '../../src/resources/logger.js';
import { RequestMetrics } from '../../src/resources/requestMetrics.js';
import { OperationDeadline } from '../../src/resources/operationDeadline.js';

// There is currently an issue with node 20.12 and fetch mocking. A quick fix is to first call fetch so it's getter
// get properly instantiated, which allow it to be mocked properly.
// Issue: https://github.com/nodejs/node/issues/52015
// PR fix: https://github.com/nodejs/node/pull/52275
globalThis.fetch = fetch;

describe('Provider', () => {
  const provider = new Provider({
    prepareRequest: requestOptions => {
      return {
        url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      };
    },
    rateLimiter: undefined,
  });

  const logger = new Logger();

  // Helper to spy on https.request and capture options
  const spyOnHttpsRequest = (): { getCapturedOptions: () => any; restore: () => void } => {
    let capturedOptions: any;
    const originalRequest = https.request;

    (https as any).request = function (options: any, callback: any) {
      capturedOptions = options;
      return originalRequest.call(https, options, callback);
    };

    return {
      getCapturedOptions: () => capturedOptions,
      restore: () => {
        (https as any).request = originalRequest;
      },
    };
  };

  it('get', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('passes the request operation-deadline signal to the rateLimiter options', async context => {
    let capturedOptions: { credentials: unknown; logger: Logger; deadline: OperationDeadline } | undefined;

    const rateLimitedProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: {},
      }),
      rateLimiter: async (options, targetFunction) => {
        capturedOptions = options;
        return targetFunction();
      },
    });

    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });
    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const deadline = OperationDeadline.never();
    await rateLimitedProvider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline,
    });

    assert.ok(capturedOptions, 'rateLimiter should have been invoked');
    assert.equal(capturedOptions?.deadline, deadline);
  });

  it('accepts text/html type response', async context => {
    const response = new Response('', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'text/html; charset=UTF-8' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'text/html; charset=UTF-8' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);

    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'text/html; charset=UTF-8',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);

    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: '',
    });
  });

  it('accepts application/schema+json type response', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/schema+json; charset=UTF-8' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/schema+json; charset=UTF-8' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);

    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/schema+json; charset=UTF-8',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);

    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('accepts application/swagger+json type response', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/swagger+json; charset=UTF-8' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/swagger+json; charset=UTF-8' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);

    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/swagger+json; charset=UTF-8',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);

    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('accepts application/vnd.oracle.resource+json type response', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('/endpoint', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);

    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);

    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('returns the raw response body if specified', async context => {
    const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
      status: 200,
      headers: new Headers({ 'Content-Type': 'image/png' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const providerResponse = await provider.streamingGet('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: {
        Accept: 'application/json',
      },
      rawBody: true,
    });

    assert.ok(providerResponse);
    // What matters: still returns a stream
    assert.ok(providerResponse.body instanceof ReadableStream);
  });

  it('gets an endpoint which is an absolute url', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('https://my-cdn.my-domain.com/file.png', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'https://my-cdn.my-domain.com/file.png',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('gets on provider url', async context => {
    const response = new Response('{"data": "value"}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.get('', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com',
      {
        method: 'GET',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('post with url encoded body', async context => {
    const response = new Response('{"data": "value"}', {
      status: 201,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.post(
      '/endpoint',
      {
        data: 'createdItemInfo',
      },
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
        additionnalheaders: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Additional-Header': 'value1' },
      },
    );

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'POST',
        body: 'data=createdItemInfo',
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 201,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('accepts an array as body for post request', async context => {
    const response = new Response('{"data": "value"}', {
      status: 201,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.post(
      '/endpoint',
      [
        { data: '1', data2: '2' },
        { data: '3', data2: '4' },
      ],
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
        additionnalheaders: { 'Content-Type': 'application/json-patch+json', 'X-Additional-Header': 'value1' },
      },
    );

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint',
      {
        method: 'POST',
        body: '[{"data":"1","data2":"2"},{"data":"3","data2":"4"}]',
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/json-patch+json',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 201,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('put with json body', async context => {
    const response = new Response('{"data": "value"}', {
      status: 201,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    // Removing leading '/' on endpoint to make sure we support both cases
    const actualResponse = await provider.put(
      'endpoint/123',
      {
        data: 'updatedItemInfo',
      },
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
        additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' },
      },
    );

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint/123',
      {
        method: 'PUT',
        body: JSON.stringify({ data: 'updatedItemInfo' }),
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 201,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('putBuffer with Buffer body', async context => {
    const response = new Response('{"data": "value"}', {
      status: 201,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const buffer = Buffer.from('binary data content');

    // What matters is that the body of put is a buffer
    const actualResponse = await provider.putBuffer('endpoint/123', buffer, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/octet-stream' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint/123',
      {
        method: 'PUT',
        body: buffer,
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/octet-stream',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 201,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('patch with query params', async context => {
    const response = new Response('{"data": "value"}', {
      status: 201,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.patch(
      '/endpoint/123',
      {
        data: 'updatedItemInfo',
      },
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
        queryParams: { param1: 'value1', param2: 'value2' },
        additionnalheaders: { 'X-Additional-Header': 'value1' },
      },
    );

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint/123?param1=value1&param2=value2',
      {
        method: 'PATCH',
        body: JSON.stringify({ data: 'updatedItemInfo' }),
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 201,
      headers: Object.fromEntries(response.headers.entries()),
      body: { data: 'value' },
    });
  });

  it('delete', async context => {
    const response = new Response(undefined, {
      status: 204,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const actualResponse = await provider.delete('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    });

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint/123',
      {
        method: 'DELETE',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 204,
      headers: Object.fromEntries(response.headers.entries()),
      body: undefined,
    });
  });

  it('deleteWithBody', async context => {
    const response = new Response('{"success": true}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const requestBody = { webhookIds: [1, 2, 3] };
    const actualResponse = await provider.delete(
      '/webhook',
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
        additionnalheaders: { 'X-Additional-Header': 'value1' },
      },
      requestBody,
    );

    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/webhook',
      {
        method: 'DELETE',
        body: JSON.stringify(requestBody),
        signal: OperationDeadline.never().signal,
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 200,
      headers: Object.fromEntries(response.headers.entries()),
      body: { success: true },
    });
  });

  it('uses rate limiter if provided', async context => {
    const mockRateLimiter = context.mock.fn((_context, request) => Promise.resolve(request()));

    const rateLimitedProvider = new Provider({
      prepareRequest: requestOptions => {
        return {
          url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
          headers: {
            'X-Custom-Provider-Header': 'value',
            'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
          },
        };
      },
      rateLimiter: mockRateLimiter,
    });

    const response = new Response(undefined, {
      status: 204,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const options = {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    };

    const actualResponse = await rateLimitedProvider.delete('/endpoint/123', options);

    assert.equal(mockRateLimiter.mock.calls.length, 1);
    assert.deepEqual(mockRateLimiter.mock.calls[0]?.arguments[0]?.credentials, options.credentials);
    assert.equal(fetchMock.mock.calls.length, 1);
    assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [
      'www.myApi.com/endpoint/123',
      {
        method: 'DELETE',
        body: null,
        signal: OperationDeadline.never().signal,
        headers: {
          Accept: 'application/json',
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': 'apikey#1111',
          'X-Additional-Header': 'value1',
        },
      },
    ]);
    assert.deepEqual(actualResponse, {
      status: 204,
      headers: Object.fromEntries(response.headers.entries()),
      body: undefined,
    });
  });

  it('uses custom error handler if provided', async context => {
    const rateLimitedProvider = new Provider({
      prepareRequest: requestOptions => {
        return {
          url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
          headers: {
            'X-Custom-Provider-Header': 'value',
            'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
          },
        };
      },
      rateLimiter: undefined,
      // Change from normal behavior 400 -> 429
      customErrorHandler: (responseStatus: number) =>
        responseStatus === 400 ? new HttpErrors.RateLimitExceededError('Weird provider behavior') : undefined,
    });

    const response = new Response(undefined, {
      status: 400,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const options = {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    };

    let error;
    try {
      await rateLimitedProvider.delete('/endpoint/123', options);
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.equal(error.message, 'Weird provider behavior');
  });

  it('contains the credential in the custom error handler', async context => {
    const provider = new Provider({
      prepareRequest: requestOptions => {
        return {
          url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
          headers: {
            'X-Custom-Provider-Header': 'value',
            'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
          },
        };
      },
      rateLimiter: undefined,
      customErrorHandler: (responseStatus: number, _message: string, options) => {
        if (responseStatus === 400) {
          // What matter is that we have access to the context in the error handler
          throw new HttpErrors.BadRequestError(`Error with API key ${options?.credentials.apiKey}`);
        }

        return undefined;
      },
    });

    const response = new Response(undefined, {
      status: 400,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const options = {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    };

    let error;
    try {
      await provider.delete('/endpoint/123', options);
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.equal(error.message, 'Error with API key apikey#1111');
  });

  it('passes the response headers to the custom error handler', async context => {
    let receivedHeaders: Record<string, string> | undefined;

    const provider = new Provider({
      prepareRequest: requestOptions => ({
        url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
      customErrorHandler: (_responseStatus, _message, _options, responseHeaders) => {
        receivedHeaders = responseHeaders;
        return undefined;
      },
    });

    const response = new Response('rate limited', {
      status: 429,
      headers: new Headers({ 'Retry-After': '30' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    await assert.rejects(
      provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      }),
    );

    assert.equal(receivedHeaders?.['retry-after'], '30');
  });

  it('uses default behavior if custom error handler returns undefined', async context => {
    const rateLimitedProvider = new Provider({
      prepareRequest: requestOptions => {
        return {
          url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
          headers: {
            'X-Custom-Provider-Header': 'value',
            'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
          },
        };
      },
      rateLimiter: undefined,
      // Custom Error Handler returning undefined (default behavior should apply)
      customErrorHandler: () => undefined,
    });

    const response = new Response(undefined, {
      status: 404,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const options = {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
      additionnalheaders: { 'X-Additional-Header': 'value1' },
    };

    let error;
    try {
      await rateLimitedProvider.delete('/endpoint/123', options);
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.equal(error.message, 'Not found');
  });

  it('returns valid json response', async context => {
    const response = new Response(`{ "validJson": true }`, {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json;charset=utf-8' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(providerResponse);
    assert.ok(providerResponse.body);
    assert.equal(providerResponse.body.validJson, true);
  });

  it('returns successfully on missing Content-Type header', async context => {
    const response = new Response(undefined, {
      status: 201,
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(providerResponse);
    assert.equal(providerResponse.body, undefined);
  });

  it('returns streamable response on streaming get calls', async context => {
    const response = new Response(`IMAGINE A HUGE PAYLOAD`, {
      status: 200,
      headers: new Headers({ 'Content-Type': 'video/mp4' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const providerResponse = await provider.streamingGet('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(providerResponse);
    assert.ok(providerResponse.body instanceof ReadableStream);
  });

  it('returns successfully on unexpected content-type response with no body', async context => {
    const response = new Response(null, {
      status: 201,
      headers: new Headers({ 'Content-Type': 'html/text' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const providerResponse = await provider.post(
      '/endpoint/123',
      {},
      {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      },
    );

    assert.ok(providerResponse);
    assert.strictEqual(providerResponse.status, response.status);
    assert.deepEqual(providerResponse.headers, Object.fromEntries(response.headers.entries()));
    assert.strictEqual(providerResponse.body, undefined);
  });

  it('throws on invalid json response', async context => {
    const response = new Response('{invalidJSON}', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/json' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.equal(error.message, 'Invalid JSON response');
  });

  it('throws on unexpected content-type response', async context => {
    const response = new Response('text', {
      status: 200,
      headers: new Headers({ 'Content-Type': 'application/text' }),
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.equal(error.status, 500);
  });

  it('throws on status 400', async context => {
    const response = new Response('response body', {
      status: 400,
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.BadRequestError);
    assert.equal(error.message, 'response body');
  });

  it('throws on timeout', async context => {
    context.mock.method(global, 'fetch', () => {
      const error = new Error();
      error.name = 'TimeoutError';
      throw error;
    });

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Request timeout');
  });

  it('throws on abort', async context => {
    context.mock.method(global, 'fetch', () => {
      const error = new Error();
      error.name = 'AbortError';
      throw error;
    });

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Request aborted');
  });

  it('throws on unknown errors', async context => {
    context.mock.method(global, 'fetch', () => {
      throw new TypeError('foo', { cause: new Error('bar') });
    });

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.HttpError);
    assert.ok(error.message.startsWith('Unexpected error while calling the provider.'));
    assert.ok(error.message.includes('ErrorName: "TypeError"'));
    assert.ok(error.message.includes('message: "foo"'));
    assert.ok(error.message.includes('stack:'));
    assert.ok(error.message.includes('cause:'));
    assert.ok(error.message.includes('causeStack:'));
  });

  it('throws on status 429', async context => {
    const response = new Response('response body', {
      status: 429,
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    assert.equal(error.message, 'response body');
  });

  it('logs provider requests', async context => {
    const response = new Response(undefined, { status: 201 });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const loggerStub = context.mock.method(logger, 'log');

    await provider.get('/endpoint/123', {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      deadline: OperationDeadline.never(),
      logger,
    });

    assert.equal(loggerStub.mock.callCount(), 1);
    assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'info');
    assert.match(
      String(loggerStub.mock.calls[0]?.arguments[1]),
      /Connector API Request GET www.myApi.com\/endpoint\/123 201 - \d+ ms/,
    );
  });

  it('logs failed provider requests at error level', async context => {
    const response = new Response('something went wrong', { status: 500 });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const loggerStub = context.mock.method(logger, 'log');

    await assert.rejects(
      provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      }),
    );

    assert.equal(loggerStub.mock.callCount(), 1);
    assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'error');
  });

  it('parses Retry-After into the thrown RateLimitExceededError', async context => {
    const response = new Response('rate limited', {
      status: 429,
      headers: { 'Retry-After': '42' },
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    assert.equal(error.retryAfter, 42);
    assert.equal(error.responseHeaders?.['retry-after'], '42');
  });

  it('parses an HTTP-date Retry-After into seconds', async context => {
    const response = new Response('rate limited', {
      status: 429,
      headers: { 'Retry-After': new Date(Date.now() + 10_000).toUTCString() },
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    // ~10s in the future: must reflect the actual date math, not a clamp-to-zero bug.
    assert.ok(typeof error.retryAfter === 'number' && error.retryAfter >= 8 && error.retryAfter <= 11);
  });

  it('ignores an unparseable Retry-After', async context => {
    const response = new Response('rate limited', {
      status: 429,
      headers: { 'Retry-After': 'soon' },
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;

    try {
      await provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    assert.equal(error.retryAfter, undefined);
  });

  it('logs Retry-After for rate-limit responses', async context => {
    const response = new Response('rate limited', {
      status: 429,
      headers: { 'Retry-After': '42' },
    });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const loggerStub = context.mock.method(logger, 'log');

    await assert.rejects(
      provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      }),
    );

    const metadata = loggerStub.mock.calls[0]?.arguments[2] as unknown as {
      rateLimit?: { retryAfter?: number };
    };
    assert.equal(metadata.rateLimit?.retryAfter, 42);
  });

  it('does not attach rate-limit metadata to non-rate-limit errors', async context => {
    const response = new Response('something went wrong', { status: 500 });

    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    const loggerStub = context.mock.method(logger, 'log');

    await assert.rejects(
      provider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      }),
    );

    const metadata = loggerStub.mock.calls[0]?.arguments[2] as unknown as { rateLimit?: unknown };
    assert.equal(metadata.rateLimit, undefined);
  });

  it('populates retryAfter and headers on a customErrorHandler error from the response', async context => {
    const customErrorProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: { 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string },
      }),
      rateLimiter: undefined,
      customErrorHandler: () => new HttpErrors.RateLimitExceededError('custom rate limit'),
    });

    const response = new Response('rate limited', { status: 429, headers: { 'Retry-After': '30' } });
    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;
    try {
      await customErrorProvider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    assert.equal(error.message, 'custom rate limit');
    assert.equal(error.retryAfter, 30);
    assert.equal(error.responseHeaders?.['retry-after'], '30');
  });

  it('does not override a retryAfter the customErrorHandler set deliberately', async context => {
    const customErrorProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: { 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string },
      }),
      rateLimiter: undefined,
      customErrorHandler: () => new HttpErrors.RateLimitExceededError('custom', { retryAfter: 99 }),
    });

    const response = new Response('rate limited', { status: 429, headers: { 'Retry-After': '30' } });
    context.mock.method(global, 'fetch', () => Promise.resolve(response));

    let error;
    try {
      await customErrorProvider.get('/endpoint/123', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        deadline: OperationDeadline.never(),
        logger,
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.RateLimitExceededError);
    assert.equal(error.retryAfter, 99);
  });

  // Stream upload will not load the data in memory and sending a binary in the body
  it('postStream streams data without buffering', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.${requestOptions.credentials.domain ?? 'myApi.com'}`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const testData = 'test binary data for streaming';
    const stream = Readable.from([testData]);

    const scope = nock('https://www.myApi.com')
      .post('/upload', testData)
      .matchHeader('content-type', 'application/octet-stream')
      .matchHeader('accept', 'application/json')
      .matchHeader('x-custom-provider-header', 'value')
      .matchHeader('x-provider-credential-header', 'apikey#1111')
      .reply(201, { success: true, id: '12345' });

    const response = await streamProvider.postStream('/upload', stream, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(scope.isDone(), 'HTTPS request should have been made');
    assert.equal(response.status, 201);
    assert.deepEqual(response.body, { success: true, id: '12345' });
  });

  it('postStream with query params', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const testData = 'data with params';
    const stream = Readable.from([testData]);

    const scope = nock('https://www.myApi.com')
      .post('/upload?key=value&format=json', testData)
      .reply(200, { uploaded: true });

    const response = await streamProvider.postStream('/upload', stream, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      queryParams: { key: 'value', format: 'json' },
      deadline: OperationDeadline.never(),
    });

    assert.ok(scope.isDone());
    assert.equal(response.status, 200);
    assert.deepEqual(response.body, { uploaded: true });
  });

  it('postStream handles error responses', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const stream = Readable.from(['error test data']);

    nock('https://www.myApi.com').post('/upload').reply(400, 'Bad request error');

    let error;
    try {
      await streamProvider.postStream('/upload', stream, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.BadRequestError);
    assert.equal(error.message, 'Bad request error');
  });

  it('postStream with rate limiter', async () => {
    let rateLimiterCalled = false;
    let rateLimiterCredentials;

    const mockRateLimiter = async (options: any, request: () => Promise<any>) => {
      rateLimiterCalled = true;
      rateLimiterCredentials = options.credentials;
      return request();
    };

    const rateLimitedProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: mockRateLimiter,
    });

    const testData = 'rate limited data';
    const stream = Readable.from([testData]);

    nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });

    await rateLimitedProvider.postStream('/upload', stream, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(rateLimiterCalled, 'Rate limiter should have been called');
    assert.deepEqual(rateLimiterCredentials, {
      apiKey: 'apikey#1111',
      unitoCredentialId: '123',
    });
  });

  it('postStream sets timeout to 0 (no timeout)', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const testData = 'timeout test data';
    const stream = Readable.from([testData]);

    const spy = spyOnHttpsRequest();
    const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });

    try {
      await streamProvider.postStream('/upload', stream, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });

      assert.ok(scope.isDone());
      assert.equal(spy.getCapturedOptions().timeout, 0, 'Timeout should be set to 0 (no timeout)');
    } finally {
      spy.restore();
    }
  });

  it('postStream handles AbortSignal for request cancellation', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const stream = Readable.from(['abort signal test']);

    // Delay response longer than the deadline so the signal fires first
    nock('https://www.myApi.com').post('/upload').delayConnection(200).reply(201, { success: true });

    let error;
    try {
      await streamProvider.postStream('/upload', stream, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.fromMs(Date.now() + 50),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Timeout');
  });

  it('postStream handles AbortSignal timeout during request', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const stream = Readable.from(['timeout during request test']);

    // Delay response to simulate a slow server
    nock('https://www.myApi.com').post('/upload').delayConnection(200).reply(201, { success: true });

    let error;
    try {
      await streamProvider.postStream('/upload', stream, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.fromMs(Date.now() + 50),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Timeout');
  });

  it('postStream cleans up AbortSignal listener on success', async () => {
    const streamProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const testData = 'cleanup test';
    const stream = Readable.from([testData]);
    const abortController = new AbortController();

    const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });

    const response = await streamProvider.postStream('/upload', stream, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(scope.isDone());
    assert.equal(response.status, 201);

    // Verify the listener was removed by checking that aborting after completion
    // doesn't cause any side effects (if listener wasn't removed, this could cause issues)
    assert.doesNotThrow(() => {
      abortController.abort();
    }, 'Aborting after completion should not throw or cause issues');

    // Verify the signal is indeed aborted
    assert.ok(abortController.signal.aborted, 'Signal should be aborted');
  });

  it('postForm handles AbortSignal for request cancellation', async () => {
    const FormData = (await import('form-data')).default;

    const formProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const form = new FormData();
    form.append('field1', 'value1');
    form.append('field2', 'value2');

    // Delay response longer than the deadline so the signal fires first
    nock('https://www.myApi.com').post('/upload-form').delayConnection(200).reply(201, { success: true });

    let error;
    try {
      await formProvider.postForm('/upload-form', form, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.fromMs(Date.now() + 50),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Timeout');
  });

  it('postForm handles AbortSignal timeout during request', async () => {
    const FormData = (await import('form-data')).default;

    const formProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const form = new FormData();
    form.append('field1', 'value1');

    // Mock a delayed response
    nock('https://www.myApi.com').post('/upload-form').delayConnection(200).reply(201, { success: true, id: '12345' });

    let error;
    try {
      await formProvider.postForm('/upload-form', form, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.fromMs(Date.now() + 50),
      });
    } catch (e) {
      error = e;
    }

    assert.ok(error instanceof HttpErrors.TimeoutError);
    assert.equal(error.message, 'Timeout');
  });

  it('postForm successfully completes with AbortSignal provided', async () => {
    const FormData = (await import('form-data')).default;

    const formProvider = new Provider({
      prepareRequest: requestOptions => ({
        url: `https://www.myApi.com`,
        headers: {
          'X-Custom-Provider-Header': 'value',
          'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string,
        },
      }),
      rateLimiter: undefined,
    });

    const form = new FormData();
    form.append('field1', 'value1');
    form.append('field2', 'value2');

    const abortController = new AbortController();

    const scope = nock('https://www.myApi.com').post('/upload-form').reply(201, { success: true, id: '12345' });

    const response = await formProvider.postForm('/upload-form', form, {
      credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
      logger,
      deadline: OperationDeadline.never(),
    });

    assert.ok(scope.isDone());
    assert.equal(response.status, 201);
    assert.deepEqual(response.body, { success: true, id: '12345' });

    // Verify the listener was removed by checking that aborting after completion
    // doesn't cause any side effects
    assert.doesNotThrow(() => {
      abortController.abort();
    }, 'Aborting after completion should not throw or cause issues');

    // Verify the signal is indeed aborted
    assert.ok(abortController.signal.aborted, 'Signal should be aborted');
  });

  describe('response recording', () => {
    it('records JSON responses to JSONL file when env var is set', async () => {
      const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`;
      process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;

      try {
        const recordingProvider = new Provider({
          prepareRequest: () => ({
            url: 'https://www.myApi.com',
            headers: { 'Content-Type': 'application/json' },
          }),
          rateLimiter: undefined,
        });

        nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' });

        await recordingProvider.get('/items', {
          credentials: { unitoCredentialId: '123' },
          logger,
          deadline: OperationDeadline.never(),
        });

        const content = fs.readFileSync(tmpFile, 'utf-8');
        const lines = content.trim().split('\n');
        assert.equal(lines.length, 1);

        const entry = JSON.parse(lines[0]!);
        assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`);
        assert.equal(entry.url, 'https://www.myApi.com/items');
        assert.equal(entry.method, 'GET');
        assert.equal(entry.status, 200);
        assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' });
      } finally {
        delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
        try {
          fs.unlinkSync(tmpFile);
        } catch {
          /* ignore */
        }
      }
    });

    it('does not record when env var is not set', async () => {
      delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;

      const noRecordProvider = new Provider({
        prepareRequest: () => ({
          url: 'https://www.myApi.com',
          headers: { 'Content-Type': 'application/json' },
        }),
        rateLimiter: undefined,
      });

      nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' });

      await noRecordProvider.get('/no-record', {
        credentials: { unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });
      // No assertion needed — just ensure no crash.
    });

    it('does not record stream responses', async () => {
      const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`;
      process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;

      try {
        const recordingProvider = new Provider({
          prepareRequest: () => ({
            url: 'https://www.myApi.com',
            headers: {},
          }),
          rateLimiter: undefined,
        });

        nock('https://www.myApi.com')
          .get('/download')
          .reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' });

        await recordingProvider.streamingGet('/download', {
          credentials: { unitoCredentialId: '123' },
          logger,
          deadline: OperationDeadline.never(),
        });

        assert.equal(fs.existsSync(tmpFile), false);
      } finally {
        delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
        try {
          fs.unlinkSync(tmpFile);
        } catch {
          /* ignore */
        }
      }
    });

    it('increments seq across multiple requests', async () => {
      const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`;
      process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile;

      try {
        const recordingProvider = new Provider({
          prepareRequest: () => ({
            url: 'https://www.myApi.com',
            headers: { 'Content-Type': 'application/json' },
          }),
          rateLimiter: undefined,
        });

        nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' });

        await recordingProvider.get('/a', {
          credentials: { unitoCredentialId: '123' },
          logger,
          deadline: OperationDeadline.never(),
        });
        await recordingProvider.get('/b', {
          credentials: { unitoCredentialId: '123' },
          logger,
          deadline: OperationDeadline.never(),
        });

        const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n');
        assert.equal(lines.length, 2);
        const seq1 = JSON.parse(lines[0]!).seq;
        const seq2 = JSON.parse(lines[1]!).seq;
        assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`);
      } finally {
        delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH;
        try {
          fs.unlinkSync(tmpFile);
        } catch {
          /* ignore */
        }
      }
    });
  });

  describe('request metrics', () => {
    it('records api call duration in requestMetrics via fetchWrapper', async context => {
      const metrics = RequestMetrics.startRequest();
      const response = new Response('{"data": "value"}', {
        status: 200,
        headers: new Headers({ 'Content-Type': 'application/json' }),
      });

      context.mock.method(global, 'fetch', () => Promise.resolve(response));

      await provider.get('/endpoint', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      const result = metrics.endRequest();
      assert.equal(result.apiCallCount, 1);
      assert.ok(result.totalApiDurationNs >= 0, 'Duration should be recorded');
    });

    it('records multiple api calls in the same requestMetrics', async context => {
      const metrics = RequestMetrics.startRequest();

      context.mock.method(global, 'fetch', () =>
        Promise.resolve(
          new Response('{"data": "value"}', {
            status: 200,
            headers: new Headers({ 'Content-Type': 'application/json' }),
          }),
        ),
      );

      await provider.get('/endpoint1', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      await provider.post(
        '/endpoint2',
        { key: 'value' },
        {
          credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
          logger,
          requestMetrics: metrics,
          deadline: OperationDeadline.never(),
        },
      );

      const result = metrics.endRequest();
      assert.equal(result.apiCallCount, 2);
      assert.ok(result.totalApiDurationNs >= 0);
    });

    it('works without requestMetrics (backward compatible)', async context => {
      const response = new Response('{"data": "value"}', {
        status: 200,
        headers: new Headers({ 'Content-Type': 'application/json' }),
      });

      context.mock.method(global, 'fetch', () => Promise.resolve(response));

      // No requestMetrics passed — should not throw
      const result = await provider.get('/endpoint', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        deadline: OperationDeadline.never(),
      });

      assert.equal(result.status, 200);
    });

    it('records api call duration from postForm', async () => {
      const metrics = RequestMetrics.startRequest();
      const httpsProvider = new Provider({
        prepareRequest: () => ({
          url: 'https://www.myApi.com',
          headers: {},
        }),
        rateLimiter: undefined,
      });

      const scope = nock('https://www.myApi.com').post('/upload').reply(201, { success: true });

      const FormData = (await import('form-data')).default;
      const form = new FormData();
      form.append('file', Buffer.from('test data'), 'test.txt');

      await httpsProvider.postForm('/upload', form, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      const postFormResult = metrics.endRequest();
      assert.equal(postFormResult.apiCallCount, 1);
      assert.ok(postFormResult.totalApiDurationNs > 0);
      scope.isDone();
    });

    it('records throttle delay when the rate limiter defers the call', async context => {
      const metrics = RequestMetrics.startRequest();
      const throttleDelayMs = 50;

      const deferringProvider = new Provider({
        prepareRequest: () => ({
          url: 'www.myApi.com',
          headers: {},
        }),
        rateLimiter: (_options, request) =>
          new Promise(resolve => {
            setTimeout(() => {
              resolve(request());
            }, throttleDelayMs);
          }),
      });

      context.mock.method(global, 'fetch', () =>
        Promise.resolve(
          new Response('{"data": "value"}', {
            status: 200,
            headers: new Headers({ 'Content-Type': 'application/json' }),
          }),
        ),
      );

      await deferringProvider.get('/endpoint', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      const result = metrics.endRequest();
      const recordedThrottleDelayMs = result.totalThrottleDelayNs / 1_000_000;
      assert.ok(
        recordedThrottleDelayMs >= throttleDelayMs * 0.8,
        `Expected recorded throttle delay >= ${throttleDelayMs * 0.8}ms but got ${recordedThrottleDelayMs}ms`,
      );
    });

    it('records negligible throttle delay when the rate limiter fires immediately', async context => {
      const metrics = RequestMetrics.startRequest();

      const immediateProvider = new Provider({
        prepareRequest: () => ({
          url: 'www.myApi.com',
          headers: {},
        }),
        rateLimiter: (_options, request) => request(),
      });

      context.mock.method(global, 'fetch', () =>
        Promise.resolve(
          new Response('{"data": "value"}', {
            status: 200,
            headers: new Headers({ 'Content-Type': 'application/json' }),
          }),
        ),
      );

      await immediateProvider.get('/endpoint', {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      const result = metrics.endRequest();
      const recordedThrottleDelayMs = result.totalThrottleDelayNs / 1_000_000;
      assert.ok(
        recordedThrottleDelayMs < 10,
        `Expected negligible throttle delay but got ${recordedThrottleDelayMs}ms`,
      );
    });

    it('records api call duration from postStream', async () => {
      const metrics = RequestMetrics.startRequest();
      const testData = 'binary stream data';
      const httpsProvider = new Provider({
        prepareRequest: () => ({
          url: 'https://www.myApi.com',
          headers: {},
        }),
        rateLimiter: undefined,
      });

      const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });

      const stream = Readable.from(Buffer.from(testData));

      await httpsProvider.postStream('/upload', stream, {
        credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
        logger,
        requestMetrics: metrics,
        deadline: OperationDeadline.never(),
      });

      const postStreamResult = metrics.endRequest();
      assert.equal(postStreamResult.apiCallCount, 1);
      assert.ok(postStreamResult.totalApiDurationNs > 0);
      scope.isDone();
    });
  });
});
