/**
 * Tests for src/managers/tailscale-manager.js
 *
 * Strategy: stub `child_process.execFile` so the manager calls a fake `tailscale`
 * CLI we control in-memory. This lets us exercise every code path — success,
 * CLI missing, tailscaled down, malformed JSON, cache hit/miss, IPv4 vs IPv6
 * selection, device-list shape — without depending on a real Tailscale install.
 *
 * The mock is a single function that inspects the args and resolves accordingly,
 * so we don't have to count invocations.
 */

'use strict';

jest.mock('child_process', () => ({
  execFile: jest.fn(),
}));

const { execFile } = require('child_process');

const tm = require('../src/managers/tailscale-manager');

/**
 * Configure the mock to behave like a specific tailscale binary.
 *
 *   mode: 'ok-version'         → `tailscale version` returns 1.98.4; everything else fails
 *   mode: 'ok-status'          → `tailscale version` AND `tailscale status --json` return ok
 *                                with the given status fixture
 *   mode: 'no-cli'             → everything rejects with ENOENT
 *   mode: 'no-daemon'          → version ok, status rejects with code 1
 *   mode: 'malformed-status'   → version ok, status returns malformed JSON
 */
function configureMock(mode, opts = {}) {
  execFile.mockImplementation((cmd, args, optsArg, cb) => {
    // Handle both 3-arg and 4-arg call shapes (promisify passes 3, manual passes 4)
    if (typeof optsArg === 'function') {
      cb = optsArg;
    }
    const isVersion = Array.isArray(args) && args[0] === 'version';
    const isStatus = Array.isArray(args) && args[0] === 'status';

    // Match the real `execFile` callback signature: cb(err, {stdout, stderr})
    // (modern util.promisify(execFile) resolves with {stdout, stderr})
    const ok = (out, err = '') => process.nextTick(() => cb(null, { stdout: out, stderr: err }));
    const fail = (err) => process.nextTick(() => cb(err));

    if (mode === 'no-cli') {
      const err = new Error('spawn tailscale ENOENT');
      err.code = 'ENOENT';
      return fail(err);
    }

    if (isVersion) {
      if (mode === 'ok-version' || mode === 'ok-status' || mode === 'no-daemon' || mode === 'malformed-status') {
        return ok('1.98.4\n');
      }
    }

    if (isStatus) {
      if (mode === 'no-daemon') {
        const err = new Error('tailscaled not running');
        err.code = 1;
        return fail(err);
      }
      if (mode === 'malformed-status') {
        return ok('not json{{{');
      }
      if (mode === 'ok-status') {
        return ok(JSON.stringify(opts.status || {}));
      }
    }

    // Default: fail
    const err = new Error(`unhandled mock invocation: ${cmd} ${(args||[]).join(' ')}`);
    err.code = 1;
    fail(err);
  });
}

const RUNNING_STATUS = {
  Version: '1.98.4',
  BackendState: 'Running',
  Self: {
    HostName: 'vmi3080415',
    TailscaleIPs: ['100.121.150.22', 'fd7a:115c:a1e0::1539:9616'],
  },
  Peer: {
    p1: {
      ID: 'p1',
      HostName: 'peer1',
      DNSName: 'peer1.tail.ts.net',
      TailscaleIPs: ['100.100.100.1', 'fd7a::5'],
      OS: 'linux',
      Online: true,
      LastSeen: '2026-07-06T10:00:00Z',
      UserID: 'u1',
      KeyExpiry: '2026-08-01T00:00:00Z',
      Tags: ['tag:server'],
      ExitNode: false,
      RxBytes: 100,
      TxBytes: 200,
    },
    p2: {
      ID: 'p2',
      HostName: 'peer2',
      TailscaleIPs: ['100.100.100.2'],
      OS: 'iOS',
      Online: false,
    },
  },
};

beforeEach(() => {
  tm.invalidateCache();
  execFile.mockReset();
});

describe('tailscale-manager', () => {
  describe('isTailscaleIP() — re-exported from network-detector (DC-031)', () => {
    test.each([
      ['100.64.0.1', true],
      ['100.121.150.22', true],
      ['100.127.255.255', true],
      ['100.63.255.255', false],
      ['100.128.0.0', false],
      ['192.168.1.5', false],
      ['172.17.0.6', false],
      ['', false],
      [null, false],
      [undefined, false],
      ['not.an.ip', false],
      ['100.x.y.z', false],
      ['100.999.0.0', false],
    ])('isTailscaleIP(%p) === %p', (input, expected) => {
      expect(tm.isTailscaleIP(input)).toBe(expected);
    });
  });

  describe('getStatus()', () => {
    test('returns parsed JSON on success', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      const s = await tm.getStatus();
      expect(s.BackendState).toBe('Running');
      expect(s.Self.HostName).toBe('vmi3080415');
      expect(s.Peer.p1.HostName).toBe('peer1');
      expect(s.Peer.p2.Online).toBe(false);
    });

    test('returns null when CLI is missing', async () => {
      configureMock('no-cli');
      expect(await tm.getStatus()).toBeNull();
    });

    test('returns null when tailscaled is down', async () => {
      configureMock('no-daemon');
      expect(await tm.getStatus()).toBeNull();
    });

    test('returns null when stdout is malformed JSON', async () => {
      configureMock('malformed-status');
      expect(await tm.getStatus()).toBeNull();
    });

    test('caches successful results within CACHE_TTL_MS', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      const a = await tm.getStatus();
      const b = await tm.getStatus();
      expect(a).toBe(b); // same reference
    });

    test('does NOT cache failed status fetches (so we retry on next call)', async () => {
      // version succeeds (CLI present) but status returns malformed JSON.
      // _isInstalled caches the positive result for 1 hour (correctly —
      // we don't want to re-probe for the CLI on every request). However,
      // a failed status fetch returns null without being cached, so the
      // next getStatus() must retry the status command.
      configureMock('malformed-status');
      await tm.getStatus();
      const before = execFile.mock.calls.length;
      await tm.getStatus();
      const after = execFile.mock.calls.length;
      // Second getStatus: _isInstalled hits cache (no call); status re-exec'd (1 call).
      // So we expect exactly 1 additional execFile call from the second getStatus.
      expect(after - before).toBe(1);
    });
  });

  describe('getLocalIP()', () => {
    test('returns the first IPv4 TailscaleIP from Self', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      expect(await tm.getLocalIP()).toBe('100.121.150.22');
    });

    test('returns null when status is null (CLI missing)', async () => {
      configureMock('no-cli');
      expect(await tm.getLocalIP()).toBeNull();
    });

    test('returns null when Self has no TailscaleIPs', async () => {
      configureMock('ok-status', { status: { BackendState: 'Running', Self: {}, Peer: {} } });
      expect(await tm.getLocalIP()).toBeNull();
    });

    test('returns null when only IPv6 is assigned', async () => {
      configureMock('ok-status', {
        status: { BackendState: 'Running', Self: { TailscaleIPs: ['fd7a:115c::1'] }, Peer: {} },
      });
      expect(await tm.getLocalIP()).toBeNull();
    });

    test('returns null when Self is missing entirely', async () => {
      configureMock('ok-status', { status: { BackendState: 'Running', Peer: {} } });
      expect(await tm.getLocalIP()).toBeNull();
    });
  });

  describe('getSummary()', () => {
    test('returns installed:false when CLI missing', async () => {
      configureMock('no-cli');
      const s = await tm.getSummary();
      expect(s.installed).toBe(false);
      expect(s.connected).toBe(false);
      expect(s.message).toMatch(/not found/i);
    });

    test('returns installed:true, connected:false when tailscaled down', async () => {
      configureMock('no-daemon');
      const s = await tm.getSummary();
      expect(s.installed).toBe(true);
      expect(s.connected).toBe(false);
      expect(s.message).toMatch(/not reachable/i);
    });

    test('returns full summary on success', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      const s = await tm.getSummary();
      expect(s.installed).toBe(true);
      expect(s.connected).toBe(true);
      expect(s.backendState).toBe('Running');
      expect(s.hostname).toBe('vmi3080415');
      expect(s.ip).toBe('100.121.150.22');
      expect(s.ipv6).toBe('fd7a:115c:a1e0::1539:9616');
      expect(s.peerCount).toBe(2);
      expect(s.onlinePeerCount).toBe(1);
    });

    test('handles missing Peer field', async () => {
      configureMock('ok-status', {
        status: { BackendState: 'Running', Self: RUNNING_STATUS.Self },
      });
      const s = await tm.getSummary();
      expect(s.peerCount).toBe(0);
      expect(s.onlinePeerCount).toBe(0);
    });
  });

  describe('getDevices()', () => {
    test('returns empty array when CLI missing', async () => {
      configureMock('no-cli');
      expect(await tm.getDevices()).toEqual([]);
    });

    test('returns empty array when Peer is missing', async () => {
      configureMock('ok-status', { status: { BackendState: 'Running', Self: RUNNING_STATUS.Self } });
      expect(await tm.getDevices()).toEqual([]);
    });

    test('shapes each peer into dashboard-friendly form', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      const devices = await tm.getDevices();
      expect(devices).toHaveLength(2);
      const d1 = devices.find(d => d.id === 'p1');
      expect(d1.hostname).toBe('peer1');
      expect(d1.dnsName).toBe('peer1.tail.ts.net');
      expect(d1.ip).toBe('100.100.100.1');
      expect(d1.ips).toEqual(['100.100.100.1', 'fd7a::5']);
      expect(d1.os).toBe('linux');
      expect(d1.online).toBe(true);
      expect(d1.user).toBe('u1');
      expect(d1.tags).toEqual(['tag:server']);
      expect(d1.isExitNode).toBe(false);
      expect(d1.rxBytes).toBe(100);
      expect(d1.txBytes).toBe(200);
      expect(d1.keyExpiry).toBe('2026-08-01T00:00:00Z');
    });

    test('handles missing optional peer fields gracefully', async () => {
      configureMock('ok-status', {
        status: { BackendState: 'Running', Peer: { minimal: { HostName: 'min' } } },
      });
      const devices = await tm.getDevices();
      expect(devices).toHaveLength(1);
      expect(devices[0].hostname).toBe('min');
      expect(devices[0].ip).toBeNull();
      expect(devices[0].ips).toEqual([]);
      expect(devices[0].tags).toEqual([]);
      expect(devices[0].online).toBe(false);
      expect(devices[0].isExitNode).toBe(false);
      expect(devices[0].rxBytes).toBe(0);
      expect(devices[0].txBytes).toBe(0);
    });
  });

  describe('getAccessToken()', () => {
    test('returns null (placeholder for OAuth-cached token)', async () => {
      expect(await tm.getAccessToken()).toBeNull();
    });
  });

  describe('syncAPI()', () => {
    test('returns a synced result with ISO timestamp', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      const r = await tm.syncAPI();
      expect(r.synced).toBe(true);
      expect(r.at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
    });

    test('invalidates the cache so next getStatus re-execs', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      // Prime cache
      await tm.getStatus();
      const callsBeforeSync = execFile.mock.calls.length;
      await tm.syncAPI();
      await tm.getStatus();
      const callsAfterSync = execFile.mock.calls.length;
      // After sync, getStatus should re-exec (version + status = 2 calls)
      expect(callsAfterSync).toBeGreaterThan(callsBeforeSync);
    });
  });

  describe('startSyncTimer / stopSyncTimer', () => {
    afterEach(() => {
      tm.stopSyncTimer();
      jest.useRealTimers();
    });

    test('fires the callback on interval and stops cleanly', () => {
      jest.useFakeTimers();
      const cb = jest.fn();
      tm.startSyncTimer(1000, cb);
      jest.advanceTimersByTime(3500);
      expect(cb).toHaveBeenCalledTimes(3);
      tm.stopSyncTimer();
      jest.advanceTimersByTime(5000);
      expect(cb).toHaveBeenCalledTimes(3);
    });

    test('second startSyncTimer call is a no-op while one is running', () => {
      jest.useFakeTimers();
      const cb1 = jest.fn();
      const cb2 = jest.fn();
      tm.startSyncTimer(1000, cb1);
      tm.startSyncTimer(1000, cb2);
      jest.advanceTimersByTime(2500);
      // Only the first callback should fire
      expect(cb1).toHaveBeenCalled();
      expect(cb2).not.toHaveBeenCalled();
    });
  });

  describe('invalidateCache()', () => {
    test('forces a re-fetch on next getStatus', async () => {
      configureMock('ok-status', { status: RUNNING_STATUS });
      await tm.getStatus();
      const callsBefore = execFile.mock.calls.length;
      tm.invalidateCache();
      await tm.getStatus();
      const callsAfter = execFile.mock.calls.length;
      expect(callsAfter).toBeGreaterThan(callsBefore);
    });
  });

  describe('module API surface (regression guard)', () => {
    test('exports the documented functions', () => {
      const expected = [
        'getStatus', 'getLocalIP', 'getSummary', 'getDevices',
        'isTailscaleIP', 'invalidateCache', 'getAccessToken',
        'startSyncTimer', 'stopSyncTimer', 'syncAPI',
      ];
      for (const fn of expected) {
        expect(typeof tm[fn]).toBe('function');
      }
    });
  });

  describe('CLI binary path', () => {
    test('default is /usr/bin/tailscale', () => {
      expect(tm._CLI_BIN).toBe('/usr/bin/tailscale');
    });

    test('respects TAILSCALE_BIN env var', () => {
      jest.resetModules();
      process.env.TAILSCALE_BIN = '/custom/path/tailscale';
      const tm2 = require('../src/managers/tailscale-manager');
      expect(tm2._CLI_BIN).toBe('/custom/path/tailscale');
      delete process.env.TAILSCALE_BIN;
    });
  });
});