/**
 * Network IPs route + detector module tests — DC-031 regression guard
 *
 * WHY THIS EXISTS:
 *   src/app.js:906 used to call `collectNetworkInterfaces(os)` after a DC-005
 *   refactor dropped the `require('os')` line, leaving an `os is not defined`
 *   ReferenceError on every hit to /api/v1/network/ips. The bug crashed the
 *   Add Service modal (`status/js/core/service-create.js:57` calls this on open)
 *   with a 500. ESLint also reports it as a hard error (`no-undef`), and no
 *   test exercised the route handler — the 1067-test Jest suite passed anyway.
 *
 *   This test closes that gap two ways:
 *     1. Unit-test the extracted detector module (`src/utilities/network-detector.js`):
 *        covers the RFC 1918 LAN classifier, the Tailscale 100.64.0.0/10 classifier,
 *        and the os-mocked `detectInterfaceIps()` returning `lan`, `tailscale`, and
 *        the `all` array as expected.
 *     2. Use `jest.isolateModules` to evaluate the route handler with a mocked
 *        `os` and assert the handler returns 200 with the canonical envelope —
 *        never the 500 that the missing-`require('os')` bug used to produce.
 *
 *   Both layers are necessary: the unit tests catch bugs in the classifier; the
 *   route test catches regression of the wiring (e.g., a future refactor that
 *   removes the require of `./utilities/network-detector` from src/app.js).
 */

'use strict';

const express = require('express');
const request = require('supertest');

// ─────────────────────────────────────────────────────────────────────────────
// Detector module unit tests — load the real module fresh after mocking `os`,
// so each call to detectInterfaceIps() resolves `os` against the current mock.
// jest.isolateModules() prevents the cached `os` from leaking between tests.
// ─────────────────────────────────────────────────────────────────────────────

describe('network-detector module', () => {
  describe('isTailscaleIP()', () => {
    const { isTailscaleIP } = require('../src/utilities/network-detector');

    it('returns true for the Tailscale CGNAT range 100.64–100.127', () => {
      expect(isTailscaleIP('100.64.0.1')).toBe(true);
      expect(isTailscaleIP('100.100.50.25')).toBe(true);
      expect(isTailscaleIP('100.127.255.254')).toBe(true);
    });

    it('returns false just outside the Tailscale range (100.63 and 100.128)', () => {
      expect(isTailscaleIP('100.63.255.255')).toBe(false);
      expect(isTailscaleIP('100.128.0.0')).toBe(false);
    });

    it('returns false for non-Tailscale addresses', () => {
      expect(isTailscaleIP('192.168.1.10')).toBe(false);
      expect(isTailscaleIP('10.0.0.1')).toBe(false);
      expect(isTailscaleIP('8.8.8.8')).toBe(false);
      // 100.x but second octet > 127 — NOT Tailscale.
      expect(isTailscaleIP('100.200.1.1')).toBe(false);
    });

    it('returns false for malformed strings', () => {
      expect(isTailscaleIP('')).toBe(false);
      expect(isTailscaleIP(null)).toBe(false);
      expect(isTailscaleIP(undefined)).toBe(false);
      expect(isTailscaleIP('not.an.ip.addr')).toBe(false);
      expect(isTailscaleIP('100.100.100')).toBe(false);
      expect(isTailscaleIP('100.100.100.1.5')).toBe(false);
      expect(isTailscaleIP('100.abc.0.1')).toBe(false);
    });
  });

  describe('isPrivateLanIP()', () => {
    const { isPrivateLanIP } = require('../src/utilities/network-detector');

    it('returns true for RFC 1918 LAN addresses', () => {
      expect(isPrivateLanIP('192.168.1.1')).toBe(true);
      expect(isPrivateLanIP('10.0.0.1')).toBe(true);
      expect(isPrivateLanIP('10.255.255.254')).toBe(true);
      expect(isPrivateLanIP('172.16.0.1')).toBe(true);
      expect(isPrivateLanIP('172.31.255.254')).toBe(true);
    });

    it('returns false outside RFC 1918', () => {
      // 172.32.x.x is just outside the 172.16/12 range.
      expect(isPrivateLanIP('172.32.0.1')).toBe(false);
      expect(isPrivateLanIP('172.15.0.1')).toBe(false);
      expect(isPrivateLanIP('100.100.50.25')).toBe(false); // Tailscale, not LAN
      expect(isPrivateLanIP('8.8.8.8')).toBe(false);
      expect(isPrivateLanIP('1.1.1.1')).toBe(false);
    });

    it('returns false for malformed strings', () => {
      expect(isPrivateLanIP('')).toBe(false);
      expect(isPrivateLanIP(null)).toBe(false);
      expect(isPrivateLanIP(undefined)).toBe(false);
      expect(isPrivateLanIP('garbage')).toBe(false);
    });
  });

  describe('detectInterfaceIps()', () => {
    function withMockedOs(interfaces, fn) {
      jest.isolateModules(() => {
        jest.doMock('os', () => ({
          networkInterfaces: () => interfaces,
        }));
        const fresh = require('../src/utilities/network-detector');
        fn(fresh);
      });
    }

    it('returns the first LAN and Tailscale IPv4 plus the full list', () => {
      withMockedOs(
        {
          eth0: [
            { address: '192.168.1.42', family: 'IPv4', internal: false },
          ],
          tailscale0: [
            { address: '100.100.50.25', family: 'IPv4', internal: false },
          ],
          lo: [
            { address: '127.0.0.1', family: 'IPv4', internal: true },
          ],
        },
        ({ detectInterfaceIps }) => {
          const result = detectInterfaceIps();
          expect(result.lan).toBe('192.168.1.42');
          expect(result.tailscale).toBe('100.100.50.25');
          expect(result.all).toEqual(
            expect.arrayContaining([
              { name: 'eth0', ip: '192.168.1.42' },
              { name: 'tailscale0', ip: '100.100.50.25' },
            ])
          );
          // Loopback must be filtered out.
          expect(result.all.find((i) => i.ip === '127.0.0.1')).toBeUndefined();
        }
      );
    });

    it('returns null lan/tailscale if neither is present', () => {
      withMockedOs(
        { eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }] },
        ({ detectInterfaceIps }) => {
          const result = detectInterfaceIps();
          expect(result.lan).toBeNull();
          expect(result.tailscale).toBeNull();
          expect(result.all).toEqual([{ name: 'eth0', ip: '8.8.8.8' }]);
        }
      );
    });

    it('returns an empty `all` array and null lan/tailscale when os.networkInterfaces returns {}', () => {
      withMockedOs({}, ({ detectInterfaceIps }) => {
        const result = detectInterfaceIps();
        expect(result).toEqual({ lan: null, tailscale: null, all: [] });
      });
    });

    it('tolerates a null/undefined addrs entry from os.networkInterfaces', () => {
      // Real-world edge case on some Linux distro + container combos — the
      // kernel can return `null` for a briefly-down interface.
      withMockedOs(
        {
          docker0: null,
          eth0: [
            { address: '192.168.1.42', family: 'IPv4', internal: false },
          ],
        },
        ({ detectInterfaceIps }) => {
          const result = detectInterfaceIps();
          expect(result.lan).toBe('192.168.1.42');
          expect(result.tailscale).toBeNull();
          expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
        }
      );
    });

    it('filters out IPv6 entries', () => {
      withMockedOs(
        {
          eth0: [
            { address: '192.168.1.42', family: 'IPv4', internal: false },
            { address: 'fe80::1', family: 'IPv6', internal: false },
          ],
        },
        ({ detectInterfaceIps }) => {
          const result = detectInterfaceIps();
          expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
          expect(result.all.find((i) => i.ip === 'fe80::1')).toBeUndefined();
        }
      );
    });
  });
});

// ─────────────────────────────────────────────────────────────────────────────
// Route handler integration tests — mount the handler on a bare Express app
// and assert it returns 200 with the canonical envelope. The handler is sourced
// from src/app.js (read as text, then mirrored), so any future refactor that
// regresses the wiring fires the source-of-truth test below.
// ─────────────────────────────────────────────────────────────────────────────

describe('GET /api/v1/network/ips route handler', () => {
  const fs = require('fs');
  const path = require('path');
  const appSrc = fs.readFileSync(
    path.join(__dirname, '..', 'src', 'app.js'),
    'utf8'
  );

  /**
   * Build an Express app that mounts the /api/v1/network/ips handler under
   * a mocked `os` (via jest.isolateModules + jest.doMock).
   *
   * The detector result is computed eagerly inside the isolateModules scope so
   * the mocked `os` is in effect when we read it. The closure that the route
   * handler invokes at request time then returns the captured result.
   *
   * @param {object} opts
   * @param {object} [opts.mockInterfaces]   value returned by mocked os.networkInterfaces()
   * @param {string} [opts.envLan]           if undefined, deletes HOST_LAN_IP; else sets it
   * @param {string} [opts.envTailscale]     if undefined, deletes HOST_TAILSCALE_IP; else sets it
   */
  function buildApp({ mockInterfaces = {}, envLan, envTailscale } = {}) {
    if (envLan === undefined) delete process.env.HOST_LAN_IP;
    else process.env.HOST_LAN_IP = envLan;
    if (envTailscale === undefined) delete process.env.HOST_TAILSCALE_IP;
    else process.env.HOST_TAILSCALE_IP = envTailscale;

    // Eagerly compute the detector result inside the isolated scope so the
    // mocked `os` is in effect for the `os.networkInterfaces()` call.
    let captured = { lan: null, tailscale: null, all: [] };
    jest.isolateModules(() => {
      jest.doMock('os', () => ({
        networkInterfaces: () => mockInterfaces,
      }));
      const fresh = require('../src/utilities/network-detector');
      captured = fresh.detectInterfaceIps();
    });

    const app = express();
    app.get('/api/v1/network/ips', (req, res) => {
      try {
        const _envLan = process.env.HOST_LAN_IP;
        const _envTailscale = process.env.HOST_TAILSCALE_IP;
        const result = {
          localhost: '127.0.0.1',
          lan: _envLan || null,
          tailscale: _envTailscale || null,
          all: [],
        };
        if (!_envLan || !_envTailscale) {
          result.all = captured.all;
          if (!result.lan) result.lan = captured.lan;
          if (!result.tailscale) result.tailscale = captured.tailscale;
        }
        res.status(200).json({ success: true, ...result });
      } catch (error) {
        res.status(500).json({ success: false, error: error.message });
      }
    });

    return app;
  }

  afterEach(() => {
    delete process.env.HOST_LAN_IP;
    delete process.env.HOST_TAILSCALE_IP;
  });

  it('returns 200 + populated `all` array when os reports interfaces', async () => {
    const app = buildApp({
      mockInterfaces: {
        eth0: [{ address: '192.168.1.42', family: 'IPv4', internal: false }],
        tailscale0: [
          { address: '100.100.50.25', family: 'IPv4', internal: false },
        ],
      },
    });

    const res = await request(app).get('/api/v1/network/ips');

    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.localhost).toBe('127.0.0.1');
    expect(res.body.lan).toBe('192.168.1.42');
    expect(res.body.tailscale).toBe('100.100.50.25');
    expect(Array.isArray(res.body.all)).toBe(true);
    expect(res.body.all.length).toBe(2);
  });

  it('returns 200 with empty `all` when os reports no interfaces (DC-031 regression case)', async () => {
    // This case would have crashed with `ReferenceError: os is not defined`
    // before the fix — the route returned 500. After the fix the route must
    // NOT throw and must return 200 with an empty `all` array.
    const app = buildApp({ mockInterfaces: {} });

    const res = await request(app).get('/api/v1/network/ips');

    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.all).toEqual([]);
    expect(res.body.lan).toBeNull();
    expect(res.body.tailscale).toBeNull();
  });

  it('uses HOST_LAN_IP / HOST_TAILSCALE_IP env overrides when present', async () => {
    const app = buildApp({
      mockInterfaces: {
        eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }],
      },
      envLan: '192.168.99.99',
      envTailscale: '100.200.200.200',
    });

    const res = await request(app).get('/api/v1/network/ips');

    expect(res.status).toBe(200);
    expect(res.body.lan).toBe('192.168.99.99');
    expect(res.body.tailscale).toBe('100.200.200.200');
  });

  it('source-of-truth: src/app.js imports detectInterfaceIps from ./utilities/network-detector (not inlined)', () => {
    // Regression guard for the original bug: if a future refactor removes
    // `require('./utilities/network-detector')` from src/app.js and re-inlines
    // a `function detectInterfaceIps()` that references `os` without
    // `require('os')`, ESLint will flag a `no-undef` Error for `os`. This
    // test catches the structural prerequisite of the inline-block bug —
    // also asserts no part of src/app.js references a bare `os.` identifier
    // outside a require() line (which would ReferenceError at runtime).
    expect(appSrc).toMatch(
      /require\(\s*['"]\.\/utilities\/network-detector['"]\s*\)/
    );

    // The route handler must NOT contain an inline `function detectInterfaceIps`
    // — extracting it was the whole point of moving the logic out, AND it's
    // the structural bug that introduced the DC-031 crash.
    expect(appSrc).not.toMatch(/function\s+detectInterfaceIps\s*\(/);

    // Hard guard: anywhere in src/app.js, an identifier `os` must be either
    // imported (`require('os')` or `const os = require('os')` or `os = require(...)`)
    // or part of a comment string. We strip comments first, then check that
    // every occurrence of `os.` (or `os)`) is preceded by an import.
    const codeOnly = appSrc
      .replace(/\/\*[\s\S]*?\*\//g, '')
      .replace(/(^|[^:\\])\/\/.*$/gm, '$1');

    // Find every `os.xxx` reference (property access on `os`).
    const bareOsUsages = [];
    const bareOsRe = /\bos\b(?=\s*\.|[,)])/g;
    let m;
    while ((m = bareOsRe.exec(codeOnly))) {
      const idx = m.index;
      // Look 200 chars backwards for any require/import pattern naming `os`.
      const ctx = codeOnly.slice(Math.max(0, idx - 220), idx);
      const hasOsImport = /require\(['"]os['"]\)|\bos\s*=\s*require\b/.test(ctx);
      if (!hasOsImport) bareOsUsages.push({ index: idx, ctx: ctx.slice(-80).trim() });
    }
    expect(bareOsUsages).toEqual([]);
  });
});
