/**
 * DC-074: SSRF hardening for sites.js — `/site` and `/site/external`
 * must reject upstream hosts that resolve to private/reserved ranges
 * BEFORE they reach the Caddyfile.
 *
 * Bug class: an authenticated dashboard operator could call
 *   POST /api/v1/site        {domain: "x.example.com", upstream: "10.0.0.1:80"}
 *   POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"}
 * and end up with a Caddy site block that proxies PUBLIC traffic to an
 * INTERNAL host. Caddy runs on DNS2 (same network as the targets), so
 * the SSRF lands.
 *
 * Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`,
 * which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS
 * metadata IP) with no problem. `/site/external` used `validateURL`
 * without `blockPrivate: true` at all.
 *
 * Post-fix: a new helper `validateUpstream()` in `fleet-validation.js`
 * reuses the resolver+private-range checks fleet-validation already has
 * for DC-068, gating Caddyfile writes behind a public-IP requirement.
 * Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who
 * intentionally proxy to private targets.
 *
 * The suite covers three layers:
 *   1. Helper unit tests — validateUpstream with mocked DNS / literal IPs
 *   2. Route integration tests — POST /site and POST /site/external
 *      reject each known private range, accept public IPs and hostnames
 *   3. Regression — pre-fix payload `10.0.0.1:80` is rejected (the
 *      canonical SSRF regression proof)
 */
const express = require('express');
const request = require('supertest');

const {
  validateUpstream,
  isPrivateOrReservedIPv4,
  isPrivateOrReservedIPv6,
} = require('../../src/utilities/fleet-validation');

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------

const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });

/**
 * Build a minimal Express app that mounts /api/v1/sites with stubbed
 * caddy/dns/buildDomain/addServiceToConfig. The stubs record every call
 * so tests can assert the route does NOT mutate the Caddyfile when it
 * should reject.
 */
function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) {
  const app = express();
  app.use(express.json({ limit: '1mb' }));
  const sites = require('../../routes/sites');
  const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
  const caddy = caddyStub || {
    read: async () => '# stub caddyfile\n',
    modify: jest.fn(async () => ({ success: true })),
    adminUrl: 'http://127.0.0.1:2019',
    filePath: '/tmp/stub-Caddyfile',
  };
  const dns = dnsStub || {
    universalCreateRecord: jest.fn(async () => true),
  };
  app.use('/api/v1', sites({
    asyncHandler: wrap,
    ok: (res, data) => res.json({ ok: true, ...data }),
    successMessage: (res, msg) => res.json({ ok: true, message: msg }),
    caddy,
    dns,
    fetchT: async () => ({ ok: true, json: async () => ({}) }),
    buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`),
    addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true),
    siteConfig: { dnsServerIp: '127.0.0.1' },
    log: log || LOG(),
  }));
  // JSON error middleware — must mirror the shape sites.js's production
  // global error middleware emits so route tests can assert on it. Without
  // this, Express's default error handler returns an HTML stack trace and
  // res.body.error is undefined.
  // eslint-disable-next-line no-unused-vars
  app.use((err, req, res, next) => {
    const status = err.statusCode || 500;
    res.status(status).json({
      error: err.message || 'Internal Server Error',
      code: err.code || null,
      field: err.field || null,
    });
  });
  return { app, caddy };
}

/** Mock dns.promises.lookup to return a specific IP for any hostname.
 *  Returns an array of `{address, family}` records since fleet-validation
 *  calls `dns.lookup(name, {all: true})`. */
function mockDnsLookup(map) {
  const dns = require('dns');
  const original = dns.promises.lookup;
  dns.promises.lookup = async (hostname, opts) => {
    for (const [pattern, ip] of Object.entries(map)) {
      if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) {
        const family = ip.includes(':') ? 6 : 4;
        return [{ address: ip, family }];
      }
    }
    // Default: throw ENOTFOUND
    const err = new Error('ENOTFOUND');
    err.code = 'ENOTFOUND';
    throw err;
  };
  return () => {
    dns.promises.lookup = original;
  };
}

// ---------------------------------------------------------------------------
// 1. Helper unit tests
// ---------------------------------------------------------------------------

describe('DC-074: validateUpstream (helper)', () => {
  let restoreDns;

  beforeEach(() => {
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
  });

  afterEach(() => {
    if (restoreDns) restoreDns();
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
  });

  describe('format validation', () => {
    test('rejects empty / non-string with INVALID_UPSTREAM', async () => {
      expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
      expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
      expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
      expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
    });

    test('rejects missing port with INVALID_UPSTREAM', async () => {
      expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
    });

    test('rejects non-integer port with INVALID_PORT', async () => {
      expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
      expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
    });

    test('rejects out-of-range port with INVALID_PORT', async () => {
      expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
      expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
      expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
      expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
    });
  });

  describe('private IPv4 reject (literal)', () => {
    const PRIVATE_V4 = [
      ['127.0.0.1', 'loopback'],
      ['127.255.255.1', 'loopback'],
      ['10.0.0.1', 'RFC 1918'],
      ['172.16.0.1', 'RFC 1918'],
      ['192.168.1.1', 'RFC 1918'],
      ['169.254.169.254', 'link-local'],          // AWS IMDS
      ['100.64.0.1', 'CGNAT'],
      ['224.0.0.1', 'multicast'],
      ['255.255.255.255', 'broadcast'],
      ['0.0.0.0', 'reserved'],
    ];
    for (const [ip, wantLabel] of PRIVATE_V4) {
      test(`rejects ${ip} (${wantLabel})`, async () => {
        const r = await validateUpstream(`${ip}:80`);
        expect(r.ok).toBe(false);
        expect(r.code).toBe('PRIVATE_IPV4');
        expect(r.message).toMatch(new RegExp(wantLabel, 'i'));
      });
    }
  });

  describe('private IPv6 reject (literal)', () => {
    test('rejects ::1 (loopback)', async () => {
      const r = await validateUpstream('[::1]:80');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV6');
    });

    test('rejects fe80::1 (link-local)', async () => {
      const r = await validateUpstream('[fe80::1]:80');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV6');
    });

    test('rejects fc00::1 (ULA)', async () => {
      const r = await validateUpstream('[fc00::1]:80');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV6');
    });
  });

  describe('public IPs accepted (literal)', () => {
    test('accepts 8.8.8.8', async () => {
      const r = await validateUpstream('8.8.8.8:53');
      expect(r.ok).toBe(true);
      expect(r.host).toBe('8.8.8.8');
      expect(r.port).toBe(53);
      expect(r.family).toBe(4);
    });

    test('accepts 1.1.1.1', async () => {
      const r = await validateUpstream('1.1.1.1:443');
      expect(r.ok).toBe(true);
      expect(r.port).toBe(443);
    });
  });

  describe('hostname resolve', () => {
    test('accepts hostname that resolves to public IP', async () => {
      restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' });
      const r = await validateUpstream('public.example.com:443');
      expect(r.ok).toBe(true);
      expect(r.resolvedIp).toBe('8.8.8.8');
      expect(r.family).toBe(4);
    });

    test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => {
      restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' });
      const r = await validateUpstream('evil.example.com:80');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV4');
      expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/);
    });

    test('rejects hostname that fails to resolve', async () => {
      // mockDnsLookup default throws ENOTFOUND
      const r = await validateUpstream('does-not-exist.invalid:80');
      expect(r.ok).toBe(false);
      expect(r.code).toMatch(/DNS_/);
    });

    test('rejects hostname with invalid charset pre-DNS', async () => {
      const r = await validateUpstream('host with spaces:80');
      expect(r.ok).toBe(false);
      expect(r.code).toBe('INVALID_HOST');
    });
  });

  describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => {
    test('default rejects private IPs', async () => {
      const r = await validateUpstream('10.0.0.1:80');
      expect(r.ok).toBe(false);
    });

    test('opt-in accepts private literal IP', async () => {
      process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
      const r = await validateUpstream('10.0.0.1:80');
      expect(r.ok).toBe(true);
    });

    test('opt-in accepts private DNS-resolved host', async () => {
      process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
      restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' });
      const r = await validateUpstream('internal.example.com:80');
      expect(r.ok).toBe(true);
    });

    test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => {
      process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
      const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false });
      expect(r.ok).toBe(false);
      expect(r.code).toBe('PRIVATE_IPV4');
    });
  });
});

// ---------------------------------------------------------------------------
// 2. Route integration tests — POST /site
// ---------------------------------------------------------------------------

describe('DC-074: POST /api/v1/site — SSRF hardening', () => {
  let restoreDns;
  let caddyStub;

  beforeEach(() => {
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
    caddyStub = {
      read: async () => '# stub caddyfile\n',
      modify: jest.fn(async () => ({ success: true })),
      adminUrl: 'http://127.0.0.1:2019',
      filePath: '/tmp/stub-Caddyfile',
    };
  });

  afterEach(() => {
    if (restoreDns) restoreDns();
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
  });

  const REGRESSION_CASES = [
    ['10.0.0.1:80',          'PRIVATE_IPV4'],
    ['172.16.0.1:80',        'PRIVATE_IPV4'],
    ['192.168.1.1:80',       'PRIVATE_IPV4'],
    ['127.0.0.1:80',         'PRIVATE_IPV4'],
    ['169.254.169.254:80',   'PRIVATE_IPV4'],   // AWS IMDS
    ['100.64.0.1:80',        'PRIVATE_IPV4'],   // CGNAT
    ['224.0.0.1:80',         'PRIVATE_IPV4'],   // multicast
    ['0.0.0.0:80',           'PRIVATE_IPV4'],   // reserved
    ['[::1]:80',             'PRIVATE_IPV6'],
    ['[fc00::1]:80',         'PRIVATE_IPV6'],
  ];

  for (const [upstream, wantCode] of REGRESSION_CASES) {
    test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => {
      const { app } = createSitesApp({ caddyStub });
      const res = await request(app)
        .post('/api/v1/site')
        .send({ domain: 'evil.example.com', upstream });
      expect(res.status).toBe(400);
      expect(res.body.error).toMatch(/\[DC-074\]/);
      expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/);
      // caddy.modify() must NOT have been called (gate happens before write)
      expect(caddyStub.modify).not.toHaveBeenCalled();
    });
  }

  test('rejects DNS-resolved private IP (rebinding defense)', async () => {
    restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/10\.0\.0\.5/);
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });

  test('accepts public literal IP', async () => {
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'new.example.com', upstream: '8.8.8.8:80' });
    expect(res.status).toBe(200);
    expect(caddyStub.modify).toHaveBeenCalledTimes(1);
  });

  test('accepts hostname resolving to public IP', async () => {
    restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' });
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'new.example.com', upstream: 'real.example.com:80' });
    expect(res.status).toBe(200);
    expect(caddyStub.modify).toHaveBeenCalledTimes(1);
  });

  test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => {
    process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' });
    expect(res.status).toBe(200);
    expect(caddyStub.modify).toHaveBeenCalledTimes(1);
  });

  test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => {
    process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
    restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' });
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'lab.example.com', upstream: 'internal.lan:80' });
    expect(res.status).toBe(200);
  });

  test('rejects out-of-range port without invoking private-IP check', async () => {
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/);
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });

  test('rejects upstream with spaces (charset) without invoking private-IP check', async () => {
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'new.example.com', upstream: 'not a host:80' });
    expect(res.status).toBe(400);
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// 3. Route integration tests — POST /site/external
// ---------------------------------------------------------------------------

describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => {
  let restoreDns;
  let caddyStub;

  beforeEach(() => {
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
    caddyStub = {
      read: async () => '# stub caddyfile\n',
      modify: jest.fn(async () => ({ success: true })),
      adminUrl: 'http://127.0.0.1:2019',
      filePath: '/tmp/stub-Caddyfile',
    };
  });

  afterEach(() => {
    if (restoreDns) restoreDns();
    delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
  });

  const REGRESSION_CASES = [
    'http://10.0.0.1',
    'http://192.168.1.1',
    'http://127.0.0.1',
    'http://169.254.169.254',     // AWS IMDS via URL form
    'http://100.64.0.1',          // CGNAT — caught by validateUpstream defense-in-depth, not validateURL
    'http://0.0.0.0',
    'http://[::1]',
    'http://[fc00::1]',
  ];

  for (const externalUrl of REGRESSION_CASES) {
    test(`rejects externalUrl="${externalUrl}"`, async () => {
      const { app } = createSitesApp({ caddyStub });
      const res = await request(app)
        .post('/api/v1/site/external')
        .send({ subdomain: 'ext', externalUrl });
      // 400 from validateURL OR from validateUpstream — either path closes the gate.
      expect(res.status).toBe(400);
      expect(caddyStub.modify).not.toHaveBeenCalled();
    });
  }

  test('rejects DNS-resolved private IP', async () => {
    restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site/external')
      .send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/);
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });

  test('accepts externalUrl with public hostname', async () => {
    restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' });
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site/external')
      .send({ subdomain: 'ext', externalUrl: 'http://api.example.com' });
    expect(res.status).toBe(200);
    expect(caddyStub.modify).toHaveBeenCalledTimes(1);
  });

  test('accepts externalUrl with public literal IP', async () => {
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site/external')
      .send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' });
    expect(res.status).toBe(200);
  });

  test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => {
    process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site/external')
      .send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' });
    expect(res.status).toBe(200);
  });
});

// ---------------------------------------------------------------------------
// 4. Regression — pre-fix payload (the canonical SSRF regression proof)
// ---------------------------------------------------------------------------

describe('DC-074: regression — pre-fix payloads are now rejected', () => {
  test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => {
    const caddyStub = {
      read: async () => '',
      modify: jest.fn(async () => ({ success: true })),
      adminUrl: 'http://127.0.0.1:2019',
      filePath: '/tmp/stub-Caddyfile',
    };
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site')
      .send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' });
    expect(res.status).toBe(400);
    // Pre-fix this payload would have been accepted, the regex happily
    // matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy
    // site block would have been written that proxied public HTTPS
    // traffic at `evil.attacker.com` to the internal 10.0.0.1:80.
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });

  test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => {
    const caddyStub = {
      read: async () => '',
      modify: jest.fn(async () => ({ success: true })),
      adminUrl: 'http://127.0.0.1:2019',
      filePath: '/tmp/stub-Caddyfile',
    };
    const { app } = createSitesApp({ caddyStub });
    const res = await request(app)
      .post('/api/v1/site/external')
      .send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' });
    expect(res.status).toBe(400);
    expect(caddyStub.modify).not.toHaveBeenCalled();
  });
});

// ---------------------------------------------------------------------------
// 5. Sanity — fleet-validation helper exports still work as before
// ---------------------------------------------------------------------------

describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => {
  test('isPrivateOrReservedIPv4 still detects the same set as before', () => {
    expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true);
    expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false);
  });

  test('isPrivateOrReservedIPv6 still detects the same set as before', () => {
    expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true);
    expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false);
  });
});