/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { updateGlobalFetchTimeouts } from './fetch.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as dnsPromises from 'node:dns/promises';
import type { LookupAddress, LookupAllOptions } from 'node:dns';
import ipaddr from 'ipaddr.js';

const { setGlobalDispatcher, Agent, EnvHttpProxyAgent } = vi.hoisted(() => ({
  setGlobalDispatcher: vi.fn(),
  Agent: vi.fn(),
  EnvHttpProxyAgent: vi.fn(),
}));

vi.mock('undici', () => ({
  setGlobalDispatcher,
  Agent,
  EnvHttpProxyAgent,
}));

vi.mock('node:dns/promises', () => ({
  lookup: vi.fn(),
}));

// Import after mocks are established
const {
  isPrivateIp,
  isPrivateIpAsync,
  isAddressPrivate,
  fetchWithTimeout,
  setGlobalProxy,
  createSafeProxyAgent,
} = await import('./fetch.js');
interface ErrorWithCode extends Error {
  code?: string;
}

describe('fetch utils', () => {
  beforeEach(() => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    vi.spyOn(global, 'fetch').mockImplementation(vi.fn() as any);
    // Default DNS lookup to return a public IP, or the IP itself if valid
    vi.mocked(
      dnsPromises.lookup as (
        hostname: string,
        options: LookupAllOptions,
      ) => Promise<LookupAddress[]>,
    ).mockImplementation(async (hostname: string) => {
      if (ipaddr.isValid(hostname)) {
        return [{ address: hostname, family: hostname.includes(':') ? 6 : 4 }];
      }
      return [{ address: '93.184.216.34', family: 4 }];
    });
    vi.unstubAllEnvs();
    updateGlobalFetchTimeouts(60000);
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('isAddressPrivate', () => {
    it('should identify private IPv4 addresses', () => {
      expect(isAddressPrivate('10.0.0.1')).toBe(true);
      expect(isAddressPrivate('127.0.0.1')).toBe(true);
      expect(isAddressPrivate('172.16.0.1')).toBe(true);
      expect(isAddressPrivate('192.168.1.1')).toBe(true);
    });

    it('should identify non-routable and reserved IPv4 addresses (RFC 6890)', () => {
      expect(isAddressPrivate('0.0.0.0')).toBe(true);
      expect(isAddressPrivate('100.64.0.1')).toBe(true);
      expect(isAddressPrivate('192.0.0.1')).toBe(true);
      expect(isAddressPrivate('192.0.2.1')).toBe(true);
      expect(isAddressPrivate('192.88.99.1')).toBe(true);
      // Benchmark range (198.18.0.0/15)
      expect(isAddressPrivate('198.18.0.0')).toBe(true);
      expect(isAddressPrivate('198.18.0.1')).toBe(true);
      expect(isAddressPrivate('198.19.255.255')).toBe(true);
      expect(isAddressPrivate('198.51.100.1')).toBe(true);
      expect(isAddressPrivate('203.0.113.1')).toBe(true);
      expect(isAddressPrivate('224.0.0.1')).toBe(true);
      expect(isAddressPrivate('240.0.0.1')).toBe(true);
    });

    it('should identify private IPv6 addresses', () => {
      expect(isAddressPrivate('::1')).toBe(true);
      expect(isAddressPrivate('fc00::')).toBe(true);
      expect(isAddressPrivate('fd00::')).toBe(true);
      expect(isAddressPrivate('fe80::')).toBe(true);
      expect(isAddressPrivate('febf::')).toBe(true);
    });

    it('should identify special local addresses', () => {
      expect(isAddressPrivate('0.0.0.0')).toBe(true);
      expect(isAddressPrivate('::')).toBe(true);
      expect(isAddressPrivate('localhost')).toBe(true);
    });

    it('should identify link-local addresses', () => {
      expect(isAddressPrivate('169.254.169.254')).toBe(true);
    });

    it('should identify IPv4-mapped IPv6 private addresses', () => {
      expect(isAddressPrivate('::ffff:127.0.0.1')).toBe(true);
      expect(isAddressPrivate('::ffff:10.0.0.1')).toBe(true);
      expect(isAddressPrivate('::ffff:169.254.169.254')).toBe(true);
      expect(isAddressPrivate('::ffff:192.168.1.1')).toBe(true);
      expect(isAddressPrivate('::ffff:172.16.0.1')).toBe(true);
      expect(isAddressPrivate('::ffff:0.0.0.0')).toBe(true);
      expect(isAddressPrivate('::ffff:100.64.0.1')).toBe(true);
      expect(isAddressPrivate('::ffff:a9fe:101')).toBe(true); // 169.254.1.1
    });

    it('should identify public addresses as non-private', () => {
      expect(isAddressPrivate('8.8.8.8')).toBe(false);
      expect(isAddressPrivate('93.184.216.34')).toBe(false);
      expect(isAddressPrivate('2001:4860:4860::8888')).toBe(false);
      expect(isAddressPrivate('::ffff:8.8.8.8')).toBe(false);
    });
  });

  describe('isPrivateIp', () => {
    it('should identify private IPs in URLs', () => {
      expect(isPrivateIp('http://10.0.0.1/')).toBe(true);
      expect(isPrivateIp('https://127.0.0.1:8080/')).toBe(true);
      expect(isPrivateIp('http://localhost/')).toBe(true);
      expect(isPrivateIp('http://[::1]/')).toBe(true);
    });

    it('should identify public IPs in URLs as non-private', () => {
      expect(isPrivateIp('http://8.8.8.8/')).toBe(false);
      expect(isPrivateIp('https://google.com/')).toBe(false);
    });
  });

  describe('isPrivateIpAsync', () => {
    it('should identify private IPs directly', async () => {
      expect(await isPrivateIpAsync('http://10.0.0.1/')).toBe(true);
    });

    it('should identify domains resolving to private IPs', async () => {
      vi.mocked(
        dnsPromises.lookup as (
          hostname: string,
          options: LookupAllOptions,
        ) => Promise<LookupAddress[]>,
      ).mockImplementation(async () => [{ address: '10.0.0.1', family: 4 }]);
      expect(await isPrivateIpAsync('http://malicious.com/')).toBe(true);
    });

    it('should identify domains resolving to public IPs as non-private', async () => {
      vi.mocked(
        dnsPromises.lookup as (
          hostname: string,
          options: LookupAllOptions,
        ) => Promise<LookupAddress[]>,
      ).mockImplementation(async () => [{ address: '8.8.8.8', family: 4 }]);
      expect(await isPrivateIpAsync('http://google.com/')).toBe(false);
    });

    it('should throw error if DNS resolution fails (fail closed)', async () => {
      vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
      await expect(isPrivateIpAsync('http://unreachable.com/')).rejects.toThrow(
        'Failed to verify if URL resolves to private IP',
      );
    });

    it('should return false for invalid URLs instead of throwing verification error', async () => {
      expect(await isPrivateIpAsync('not-a-url')).toBe(false);
    });
  });

  describe('fetchWithTimeout', () => {
    it('should throw FetchError with ETIMEDOUT on an internal timeout', async () => {
      vi.mocked(global.fetch).mockImplementation(
        (_input, init) =>
          new Promise((_resolve, reject) => {
            if (init?.signal) {
              init.signal.addEventListener('abort', () => {
                const error = new Error(
                  'The operation was aborted',
                ) as ErrorWithCode;
                error.name = 'AbortError';
                error.code = 'ABORT_ERR';
                reject(error);
              });
            }
          }),
      );

      await expect(fetchWithTimeout('http://example.com', 50)).rejects.toThrow(
        'Request timed out after 50ms',
      );
    });

    it('should throw an AbortError (not ETIMEDOUT) when the caller signal is aborted', async () => {
      vi.mocked(global.fetch).mockImplementation(
        (_input, init) =>
          new Promise((_resolve, reject) => {
            const rejectWithAbortError = () => {
              const error = new Error('The operation was aborted');
              error.name = 'AbortError';
              // @ts-expect-error - for mocking purposes
              error.code = 'ABORT_ERR';
              reject(error);
            };

            // Handle the case where the signal is already aborted before
            // fetch is called (e.g. controller.abort() called synchronously).
            if (init?.signal?.aborted) {
              rejectWithAbortError();
              return;
            }

            if (init?.signal) {
              init.signal.addEventListener('abort', rejectWithAbortError, {
                once: true,
              });
            }
          }),
      );

      const controller = new AbortController();
      // Abort the external signal before the request even starts
      controller.abort();

      const rejection = fetchWithTimeout('http://example.com', 10_000, {
        signal: controller.signal,
      });

      await expect(rejection).rejects.toMatchObject({ name: 'AbortError' });
      // Must NOT be classified as a timeout
      await expect(rejection).rejects.not.toThrow('timed out');
    });
  });

  describe('setGlobalProxy', () => {
    it('should configure EnvHttpProxyAgent with experiment flag timeout and noProxy', () => {
      const proxyUrl = ' http://proxy.example.com ';
      const noProxyValue = ' localhost,127.0.0.1 ';
      vi.stubEnv('NO_PROXY', noProxyValue);

      updateGlobalFetchTimeouts(45773134);
      setGlobalProxy(proxyUrl);

      expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
        httpProxy: 'http://proxy.example.com',
        httpsProxy: 'http://proxy.example.com',
        noProxy: 'localhost,127.0.0.1',
        headersTimeout: 45773134,
        bodyTimeout: 300000,
      });
      expect(setGlobalDispatcher).toHaveBeenCalled();
    });

    it('should fall back to no_proxy if NO_PROXY is not set', () => {
      const proxyUrl = 'http://proxy.example.com';
      const noProxyValue = 'localhost,127.0.0.1';
      vi.stubEnv('NO_PROXY', undefined);
      vi.stubEnv('no_proxy', noProxyValue);

      setGlobalProxy(proxyUrl);

      expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
        expect.objectContaining({
          noProxy: noProxyValue,
        }),
      );
    });

    it('should handle empty NO_PROXY', () => {
      const proxyUrl = 'http://proxy.example.com';
      vi.stubEnv('NO_PROXY', '');

      setGlobalProxy(proxyUrl);

      expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
        expect.objectContaining({
          noProxy: '',
        }),
      );
    });

    it('should handle multi-entry NO_PROXY with trimming', () => {
      const proxyUrl = 'http://proxy.example.com';
      const noProxyValue = '  google.com, 127.0.0.1 , localhost  ';
      vi.stubEnv('NO_PROXY', noProxyValue);

      setGlobalProxy(proxyUrl);

      expect(EnvHttpProxyAgent).toHaveBeenCalledWith(
        expect.objectContaining({
          noProxy: 'google.com, 127.0.0.1 , localhost',
        }),
      );
    });
  });

  describe('createSafeProxyAgent', () => {
    it('should create an EnvHttpProxyAgent with trimmed values and default timeouts', () => {
      const proxyUrl = ' http://proxy.example.com ';
      const noProxyValue = ' localhost,127.0.0.1 ';
      vi.stubEnv('NO_PROXY', noProxyValue);

      createSafeProxyAgent(proxyUrl);

      expect(EnvHttpProxyAgent).toHaveBeenCalledWith({
        httpProxy: 'http://proxy.example.com',
        httpsProxy: 'http://proxy.example.com',
        noProxy: 'localhost,127.0.0.1',
        headersTimeout: 60000,
        bodyTimeout: 300000,
      });
    });
  });
});
