/**
 * DC-068: Fleet hostname SSRF hardening
 *
 * Tests for the fleet validation helpers (isPrivateOrReservedIPv4/IPv6,
 * isValidHostnameSyntax, validateFleetHost) and resolveAndCheckAddress.
 * Covers:
 *   - IPv4 private/reserved range detection (loopback, link-local, RFC 1918,
 *     CGNAT, multicast, broadcast, documentation)
 *   - IPv6 private/reserved range detection (loopback, link-local, ULA,
 *     multicast, IPv4-mapped)
 *   - RFC 1123 hostname syntax check
 *   - Port bounds (1..65535), port 22 rejection, missing/invalid port
 *   - Tag validation (max 20, each 1..50, no control chars)
 *   - Name validation (1..100, no control chars)
 *   - End-to-end validateFleetHost for all rejection and acceptance paths
 *   - resolveAndCheckAddress: literal IP paths, DNS-resolution success path
 *     with mocked dns.lookup, DNS-resolution failure path, and the
 *     allow-private opt-in
 *
 * The DNS path is unit-tested by replacing `dns.promises.lookup` on the
 * module instance with a mock that returns a fake A record.
 */
const {
  validateFleetHost,
  resolveAndCheckAddress,
  isPrivateOrReservedIPv4,
  isPrivateOrReservedIPv6,
  isValidHostnameSyntax,
} = require('../src/utilities/fleet-validation');

describe('DC-068: isPrivateOrReservedIPv4', () => {
  const cases = [
    // [ip, expectedIsPrivate, expectedLabelSubstring-or-null]
    ['127.0.0.1',     true,  'loopback'],
    ['127.255.255.1', true,  'loopback'],
    ['169.254.0.1',   true,  'link-local'],
    ['169.254.169.254',true,  'link-local'],   // AWS/GCP/Azure metadata
    ['10.0.0.1',      true,  'RFC 1918'],
    ['172.16.0.1',    true,  'RFC 1918'],
    ['172.31.255.1',  true,  'RFC 1918'],
    ['172.32.0.1',    false, null],
    ['192.168.1.1',   true,  'RFC 1918'],
    ['100.64.0.1',    true,  'CGNAT'],
    ['100.127.255.1', true,  'CGNAT'],
    ['100.128.0.1',   false, null],
    ['224.0.0.1',     true,  'multicast'],
    ['239.255.255.255',true, 'multicast'],
    ['255.255.255.255',true, 'broadcast'],
    ['0.0.0.0',       true,  'reserved'],
    ['192.0.2.1',     true,  'TEST-NET-1'],
    ['198.51.100.1',  true,  'TEST-NET-2'],
    ['203.0.113.1',   true,  'TEST-NET-3'],
    ['198.18.0.1',    true,  'benchmark'],
    ['198.19.255.1',  true,  'benchmark'],
    ['240.0.0.1',     true,  'reserved'],
    ['8.8.8.8',       false, null],
    ['1.1.1.1',       false, null],
    ['93.184.216.34', false, null],
  ];
  for (const [ip, wantPrivate, wantLabel] of cases) {
    it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
      const r = isPrivateOrReservedIPv4(ip);
      expect(r.isPrivate).toBe(wantPrivate);
      if (wantLabel) expect(r.label).toContain(wantLabel);
      else expect(r.label).toBeNull();
    });
  }

  it('returns isPrivate=false for non-strings', () => {
    expect(isPrivateOrReservedIPv4(null).isPrivate).toBe(false);
    expect(isPrivateOrReservedIPv4(undefined).isPrivate).toBe(false);
    expect(isPrivateOrReservedIPv4(42).isPrivate).toBe(false);
  });
  it('returns isPrivate=false for malformed IPv4', () => {
    expect(isPrivateOrReservedIPv4('1.2.3').isPrivate).toBe(false);
    expect(isPrivateOrReservedIPv4('1.2.3.4.5').isPrivate).toBe(false);
    expect(isPrivateOrReservedIPv4('256.0.0.0').isPrivate).toBe(false);
    expect(isPrivateOrReservedIPv4('1.2.3.999').isPrivate).toBe(false);
  });
});

describe('DC-068: isPrivateOrReservedIPv6', () => {
  const cases = [
    ['::1',            true,  'IPv6 loopback'],
    ['::',             true,  'IPv6 unspecified'],
    ['fe80::1',        true,  'link-local'],
    ['feb0::1',        true,  'link-local'],
    ['fc00::1',        true,  'unique-local'],
    ['fd00::1',        true,  'unique-local'],
    ['ff00::1',        true,  'multicast'],
    ['::ffff:127.0.0.1',true, 'IPv4-mapped'],
    ['::ffff:8.8.8.8',false, null],
    ['2001:4860:4860::8888',false, null], // Google IPv6
    ['2606:4700:4700::1111',false, null], // Cloudflare IPv6
  ];
  for (const [ip, wantPrivate, wantLabel] of cases) {
    it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
      const r = isPrivateOrReservedIPv6(ip);
      expect(r.isPrivate).toBe(wantPrivate);
      if (wantLabel) expect(r.label).toContain(wantLabel);
      else expect(r.label).toBeNull();
    });
  }
});

describe('DC-068: isValidHostnameSyntax', () => {
  const accept = [
    'example.com',
    'sub.example.com',
    'a-b.example.com',
    'host1',
    'a',
    'a'.repeat(63) + '.com',  // 63-char label is the max
    'very-long-host-name-with-many-segments.sub.example.com',
    'host-with-trailing-dot.',  // trailing dot is legal
    'EXAMPLE.com',  // case-insensitive
    '123.example.com',  // numeric labels allowed
  ];
  for (const h of accept) {
    it(`accepts "${h}"`, () => {
      expect(isValidHostnameSyntax(h)).toBe(true);
    });
  }
  const reject = [
    '',
    '.',
    '..',
    'a..b',  // empty label
    '-a.com',  // label can't start with hyphen
    'a-.com',  // label can't end with hyphen
    'a b.com',  // space not allowed
    '_underscore.com',  // underscore not allowed (strict RFC 1123)
    'a/b.com',  // slash not allowed
    'a$b.com',  // dollar not allowed
    'a.com/' + 'x'.repeat(255),  // 255-char label exceeds 63
    'host.with.' + 'a-63-chars-'.repeat(8) + '.com',  // total > 253 chars
  ];
  for (const h of reject) {
    it(`rejects "${h}"`, () => {
      expect(isValidHostnameSyntax(h)).toBe(false);
    });
  }
});

describe('DC-068: validateFleetHost', () => {
  const valid = (extra = {}) => ({
    name: 'Test Host',
    hostname: 'fleet.example.com',
    port: 3001,
    tags: ['prod'],
    ...extra,
  });

  it('accepts a clean public-DNS host', () => {
    const r = validateFleetHost(valid());
    expect(r.ok).toBe(true);
    expect(r.normalized.name).toBe('Test Host');
    expect(r.normalized.hostname).toBe('fleet.example.com');
    expect(r.normalized.port).toBe(3001);
  });

  it('normalises hostname to lowercase and trims name', () => {
    const r = validateFleetHost({ ...valid(), name: '  Spaced  ', hostname: 'FLEET.Example.COM' });
    expect(r.ok).toBe(true);
    expect(r.normalized.name).toBe('Spaced');
    expect(r.normalized.hostname).toBe('fleet.example.com');
  });

  it('accepts a public IPv4 literal', () => {
    const r = validateFleetHost({ ...valid(), hostname: '8.8.8.8' });
    expect(r.ok).toBe(true);
  });

  it('accepts a public IPv6 literal', () => {
    const r = validateFleetHost({ ...valid(), hostname: '2001:4860:4860::8888' });
    expect(r.ok).toBe(true);
  });

  // ── Name rejection paths ──
  it('rejects missing name with INVALID_NAME', () => {
    const r = validateFleetHost({ ...valid(), name: undefined });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_NAME');
  });
  it('rejects empty name', () => {
    const r = validateFleetHost({ ...valid(), name: '' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_NAME');
  });
  it('rejects name >100 chars', () => {
    const r = validateFleetHost({ ...valid(), name: 'x'.repeat(101) });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_NAME');
  });
  it('rejects name with control characters', () => {
    expect(validateFleetHost({ ...valid(), name: 'evil\nname' }).code).toBe('INVALID_NAME');
    expect(validateFleetHost({ ...valid(), name: 'evil\rname' }).code).toBe('INVALID_NAME');
    expect(validateFleetHost({ ...valid(), name: 'evil\x00name' }).code).toBe('INVALID_NAME');
  });

  // ── Hostname rejection paths ──
  it('rejects missing hostname with INVALID_HOSTNAME', () => {
    const r = validateFleetHost({ ...valid(), hostname: undefined });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });
  it('rejects empty hostname', () => {
    const r = validateFleetHost({ ...valid(), hostname: '' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });
  it('rejects garbage hostname', () => {
    const r = validateFleetHost({ ...valid(), hostname: 'not a valid host' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });
  it('rejects hostname with scheme prefix (url injection)', () => {
    const r = validateFleetHost({ ...valid(), hostname: 'http://evil.com' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });
  it('rejects hostname with @ (URL-credential injection)', () => {
    const r = validateFleetHost({ ...valid(), hostname: 'evil@host.com' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });

  // ── IPv4 private-range rejection paths (literal input) ──
  const privateV4 = [
    ['127.0.0.1', 'loopback'],
    ['169.254.169.254', 'link-local'],
    ['10.0.0.1', 'RFC 1918'],
    ['192.168.1.1', 'RFC 1918'],
    ['100.64.0.1', 'CGNAT'],   // Tailscale
    ['255.255.255.255', 'broadcast'],
    ['0.0.0.0', 'reserved'],
  ];
  for (const [ip, label] of privateV4) {
    it(`rejects private IPv4 literal ${ip} (${label})`, () => {
      const r = validateFleetHost({ ...valid(), hostname: ip });
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV4');
      expect(r.message).toContain(label);
    });
  }

  // ── IPv6 private-range rejection paths ──
  const privateV6 = [
    ['::1', 'IPv6 loopback'],
    ['fe80::1', 'IPv6 link-local'],
    ['fc00::1', 'IPv6 unique-local'],
    ['fd00::abcd', 'IPv6 unique-local'],
    ['::ffff:127.0.0.1', 'IPv4-mapped'],  // contains BOTH colon AND dot
  ];
  for (const [ip, label] of privateV6) {
    it(`rejects private IPv6 literal ${ip} (${label})`, () => {
      const r = validateFleetHost({ ...valid(), hostname: ip });
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV6');
      expect(r.message).toContain(label);
    });
  }

  // ── Port rejection paths ──
  it('rejects port < 1', () => {
    const r = validateFleetHost({ ...valid(), port: 0 });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_PORT');
  });
  it('rejects port > 65535', () => {
    const r = validateFleetHost({ ...valid(), port: 65536 });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_PORT');
  });
  it('rejects non-integer port', () => {
    expect(validateFleetHost({ ...valid(), port: 'three' }).code).toBe('INVALID_PORT');
    expect(validateFleetHost({ ...valid(), port: 3001.5 }).code).toBe('INVALID_PORT');
  });
  it('rejects port 22 (SSH collision)', () => {
    const r = validateFleetHost({ ...valid(), port: 22 });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_PORT');
    expect(r.message).toMatch(/22.*reserved|reserved.*22/);
  });
  it('accepts port 1, 1023, 1024, 65535', () => {
    expect(validateFleetHost({ ...valid(), port: 1 }).ok).toBe(true);
    expect(validateFleetHost({ ...valid(), port: 1023 }).ok).toBe(true);
    expect(validateFleetHost({ ...valid(), port: 1024 }).ok).toBe(true);
    expect(validateFleetHost({ ...valid(), port: 65535 }).ok).toBe(true);
  });

  // ── Tag rejection paths ──
  it('rejects non-array tags', () => {
    const r = validateFleetHost({ ...valid(), tags: 'prod' });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_TAGS');
  });
  it('rejects > 20 tags', () => {
    const r = validateFleetHost({ ...valid(), tags: Array.from({ length: 21 }, (_, i) => `t${i}`) });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_TAGS');
  });
  it('rejects empty-string tag', () => {
    const r = validateFleetHost({ ...valid(), tags: ['valid', ''] });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_TAGS');
  });
  it('rejects tag > 50 chars', () => {
    const r = validateFleetHost({ ...valid(), tags: ['x'.repeat(51)] });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_TAGS');
  });
  it('rejects tag with control characters', () => {
    const r = validateFleetHost({ ...valid(), tags: ['good', 'bad\ntag'] });
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_TAGS');
  });
  it('accepts tags omitted (defaults to [])', () => {
    const r = validateFleetHost({ name: 'h', hostname: 'fleet.example.com', port: 3001 });
    expect(r.ok).toBe(true);
    expect(r.normalized.tags).toEqual([]);
  });
});

describe('DC-068: resolveAndCheckAddress', () => {
  // The DNS code path uses `dns.promises.lookup` directly; for literal IPs
  // and IPv6, no DNS call is made. The DNS-name code path is exercised by
  // mocking dns.promises.lookup.

  it('accepts a public IPv4 literal without DNS lookup', async () => {
    const r = await resolveAndCheckAddress('8.8.8.8');
    expect(r.ok).toBe(true);
    expect(r.ip).toBe('8.8.8.8');
    expect(r.family).toBe(4);
  });

  it('accepts a public IPv6 literal', async () => {
    const r = await resolveAndCheckAddress('2001:4860:4860::8888');
    expect(r.ok).toBe(true);
    expect(r.ip).toBe('2001:4860:4860::8888');
    expect(r.family).toBe(6);
  });

  it('rejects a private IPv4 literal with opt-out', async () => {
    const r = await resolveAndCheckAddress('127.0.0.1');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('PRIVATE_IPV4');
  });

  it('accepts a private IPv4 literal when allowPrivate=true', async () => {
    const r = await resolveAndCheckAddress('192.168.1.1', { allowPrivate: true });
    expect(r.ok).toBe(true);
    expect(r.ip).toBe('192.168.1.1');
  });

  it('rejects a Tailscale (CGNAT) IPv4 literal', async () => {
    const r = await resolveAndCheckAddress('100.64.0.1');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('PRIVATE_IPV4');
  });

  it('rejects the AWS metadata endpoint 169.254.169.254', async () => {
    const r = await resolveAndCheckAddress('169.254.169.254');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('PRIVATE_IPV4');
    expect(r.message).toMatch(/link-local|metadata/i);
  });

  it('rejects IPv4-mapped IPv6 loopback', async () => {
    const r = await resolveAndCheckAddress('::ffff:127.0.0.1');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('PRIVATE_IPV6');
  });

  it('rejects garbage hostnames without DNS lookup', async () => {
    const r = await resolveAndCheckAddress('not a host');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });

  it('rejects empty hostname', async () => {
    const r = await resolveAndCheckAddress('');
    expect(r.ok).toBe(false);
    expect(r.code).toBe('INVALID_HOSTNAME');
  });

  it('rejects DNS name that does not resolve', async () => {
    // We use a reserved TLD (.invalid) which RFC 6761 guarantees will not
    // resolve in production DNS — so the test is hermetic without mocking.
    const r = await resolveAndCheckAddress('does-not-resolve.invalid');
    expect(r.ok).toBe(false);
    expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(r.code);
  });

  it('rejects DNS name that resolves to a private IP', async () => {
    // Heremetic test: dns.promises.lookup is patched on the module instance.
    const dns = require('dns');
    const originalLookup = dns.promises.lookup;
    dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
    try {
      const r = await resolveAndCheckAddress('attacker.example.com');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV4');
    } finally {
      dns.promises.lookup = originalLookup;
    }
  });

  it('accepts DNS name that resolves to a public IP', async () => {
    const dns = require('dns');
    const originalLookup = dns.promises.lookup;
    dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
    try {
      const r = await resolveAndCheckAddress('public.example.com');
      expect(r.ok).toBe(true);
      expect(r.ip).toBe('93.184.216.34');
      expect(r.family).toBe(4);
    } finally {
      dns.promises.lookup = originalLookup;
    }
  });

  it('skips private check when allowPrivate=true even for DNS-resolved address', async () => {
    const dns = require('dns');
    const originalLookup = dns.promises.lookup;
    dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
    try {
      const r = await resolveAndCheckAddress('tailnet.example.com', { allowPrivate: true });
      expect(r.ok).toBe(true);
      expect(r.ip).toBe('10.0.0.5');
    } finally {
      dns.promises.lookup = originalLookup;
    }
  });
});
