/**
 * DC-055: Host journald reader unit tests
 *
 * The reader is a security-sensitive shell-out — every test below exists
 * to prevent a regression that would let a caller pass a tainted unit
 * name or since/until/search string to journalctl. We never call the real
 * binary; every spawn is mocked by injecting an `exec` function (the
 * module accepts exec as the second argument specifically for testability).
 */

const path = require('path');
const { EventEmitter } = require('events');

const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js');

// Construct a fake child process that matches the interface journald-reader
// uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand.
function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) {
  const child = new EventEmitter();
  child.stdout = new EventEmitter();
  child.stderr = new EventEmitter();
  child.kill = killFn || (() => {});
  process.nextTick(() => {
    if (failOnSpawn) {
      const err = new Error('spawn fail');
      err.code = failOnSpawn;
      child.emit('error', err);
      return;
    }
    if (stdout) child.stdout.emit('data', Buffer.from(stdout));
    if (stderr) child.stderr.emit('data', Buffer.from(stderr));
    child.emit('exit', code, signal);
  });
  return child;
}

// Factory for an `exec` function that returns the given fake child.
function fakeExec(child) {
  return jest.fn().mockReturnValue(child);
}

describe('journald-reader', () => {
  describe('assertUnitAllowed', () => {
    const { assertUnitAllowed } = require(MODULE_PATH);

    test('accepts allow-listed bare names', () => {
      expect(assertUnitAllowed('caddy')).toBe('caddy');
      expect(assertUnitAllowed('docker')).toBe('docker');
      expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api');
    });

    test('strips .service suffix', () => {
      expect(assertUnitAllowed('caddy.service')).toBe('caddy');
      expect(assertUnitAllowed('docker.service')).toBe('docker');
    });

    test('rejects units not on the allow-list', () => {
      expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/);
      expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/);
      expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/);
    });

    test('rejects shell metacharacters and path traversal', () => {
      expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/);
      expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/);
      expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/);
      expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/);
      expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/);
    });

    test('rejects empty / non-string', () => {
      expect(() => assertUnitAllowed('')).toThrow(/unit is required/);
      expect(() => assertUnitAllowed(null)).toThrow(/unit is required/);
      expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/);
      expect(() => assertUnitAllowed(42)).toThrow(/unit is required/);
    });

    test('throws ValidationError specifically (route layer keys on .name)', () => {
      try { assertUnitAllowed('nginx'); }
      catch (e) { expect(e.name).toBe('ValidationError'); }
    });
  });

  describe('parseTail', () => {
    const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH);

    test('returns fallback on undefined', () => {
      expect(parseTail(undefined)).toBe(200);
      expect(parseTail(undefined, 50)).toBe(50);
    });

    test('clamps to MAX_TAIL_LINES', () => {
      expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES);
      expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES);
    });

    test('rejects non-positive and non-integer', () => {
      expect(() => parseTail('0')).toThrow(/positive integer/);
      expect(() => parseTail('-5')).toThrow(/positive integer/);
      expect(() => parseTail('abc')).toThrow(/positive integer/);
      expect(() => parseTail('1.5')).toThrow(/positive integer/);
      expect(() => parseTail(NaN)).toThrow(/positive integer/);
    });

    test('accepts valid integers', () => {
      expect(parseTail('1')).toBe(1);
      expect(parseTail('500')).toBe(500);
      expect(parseTail(200)).toBe(200);
    });
  });

  describe('parseTimestamp', () => {
    const { parseTimestamp } = require(MODULE_PATH);

    test('returns null on undefined/empty', () => {
      expect(parseTimestamp(undefined, 'since')).toBeNull();
      expect(parseTimestamp('', 'since')).toBeNull();
      expect(parseTimestamp(null, 'since')).toBeNull();
    });

    test('parses ISO 8601 timestamps', () => {
      const out = parseTimestamp('2026-08-18T07:00:00Z', 'since');
      expect(out).toBe('2026-08-18T07:00:00.000Z');
    });

    test('parses ISO date-only', () => {
      const out = parseTimestamp('2026-08-18', 'since');
      expect(out).toMatch(/^2026-08-18/);
    });

    test('parses unix epoch in seconds and ms', () => {
      // Use a known epoch so the test isn't sensitive to "now". The
      // expected ISO output is computed at runtime so this stays correct.
      const epochSec = 1787038846; // 2026-08-18T07:00:46Z
      const expected = new Date(epochSec * 1000).toISOString();
      expect(parseTimestamp(String(epochSec), 'since')).toBe(expected);
      expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected);
    });

    test('passes through journalctl relative syntax', () => {
      expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago');
      expect(parseTimestamp('today', 'until')).toBe('today');
    });

    test('rejects shell metacharacters in relative syntax', () => {
      expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/);
      expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/);
    });

    test('rejects strings >1024 chars', () => {
      const huge = 'a'.repeat(1025);
      expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/);
    });

    test('rejects invalid ISO', () => {
      // 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or
      // safe relative-syntax — falls through to the relative branch but
      // doesn't contain forbidden chars either, so it would pass through
      // to journalctl. Use a string with shell metacharacters instead
      // to prove the path actually rejects.
      expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow();
      // Numbers that overflow Date.parse
      expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow();
    });
  });

  describe('buildArgv', () => {
    const { buildArgv } = require(MODULE_PATH);

    test('always emits --directory + unit + --no-pager', () => {
      const argv = buildArgv({ unit: 'caddy', tail: 100 });
      expect(argv).toContain('--directory');
      expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal');
      expect(argv).toContain('--no-pager');
      expect(argv).toContain('-u');
      expect(argv[argv.indexOf('-u') + 1]).toBe('caddy');
      expect(argv).not.toContain('--follow');
    });

    test('follow flag is set when requested', () => {
      const argv = buildArgv({ unit: 'caddy', follow: true });
      expect(argv).toContain('--follow');
    });

    test('emits -n <tail> for numeric tail', () => {
      const argv = buildArgv({ unit: 'caddy', tail: 500 });
      const idx = argv.indexOf('-n');
      expect(idx).toBeGreaterThan(-1);
      expect(argv[idx + 1]).toBe('500');
    });

    test('emits --since/--until/search when provided', () => {
      const argv = buildArgv({
        unit: 'caddy', tail: 100,
        since: '2026-08-18T00:00:00Z',
        until: '2026-08-18T23:59:59Z',
        search: 'health',
      });
      expect(argv).toContain('--since');
      expect(argv).toContain('--until');
      expect(argv).toContain('-S');
      expect(argv[argv.indexOf('-S') + 1]).toBe('health');
    });

    test('emits argv as a flat string array (no shell)', () => {
      const argv = buildArgv({ unit: 'caddy', tail: 1 });
      expect(argv.every(a => typeof a === 'string')).toBe(true);
    });
  });

  describe('readEntries', () => {
    const reader = require(MODULE_PATH);

    test('parses short-output lines into structured entries', async () => {
      const child = makeFakeChild({
        stdout: [
          'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}',
          'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}',
          '',
        ].join('\n'),
      });
      const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) });
      expect(entries).toHaveLength(2);
      expect(entries[0].timestamp).toBe('Aug 18 00:42:46');
      expect(entries[0].hostname).toBe('vmi3080415');
      expect(entries[0].unit).toBe('caddy');
      expect(entries[0].text).toBe('{"level":"info","msg":"hello"}');
    });

    test('throws on ValidationError for bad unit', async () => {
      await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({
        name: 'ValidationError',
      });
    });

    test('throws on ValidationError for bad tail', async () => {
      await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({
        name: 'ValidationError',
      });
    });

    test('throws on ValidationError for shell-meta since', async () => {
      await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' }))
        .rejects.toMatchObject({ name: 'ValidationError' });
    });

    test('surfaces ENOENT as Error("journalctl unavailable")', async () => {
      const child = makeFakeChild({ failOnSpawn: 'ENOENT' });
      const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
        .then(() => null, e => e);
      expect(err.message).toBe('journalctl unavailable');
    });

    test('surfaces non-zero exit with stderr snippet', async () => {
      const child = makeFakeChild({
        stdout: '',
        stderr: 'Failed to open directory: /var/log/journal/foo\n',
        code: 1,
      });
      const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
        .then(() => null, e => e);
      expect(err.message).toMatch(/exited 1/);
      expect(err.message).toMatch(/Failed to open directory/);
    });

    test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => {
      // Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap.
      // Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB.
      const child = new EventEmitter();
      child.stdout = new EventEmitter();
      child.stderr = new EventEmitter();
      child.kill = jest.fn();
      const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER;
      const first = Math.floor(cap * 0.4);  // 40%
      const second = Math.floor(cap * 0.7);  // 70% more — total 110%
      process.nextTick(() => {
        child.stdout.emit('data', Buffer.alloc(first, 'x'));
        child.stdout.emit('data', Buffer.alloc(second, 'x'));
        // Don't emit exit — the overflow rejection doesn't depend on it.
        // Kill the child eventually so Jest can exit cleanly.
        setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50);
      });
      const execSpy = jest.fn().mockReturnValue(child);
      const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy })
        .then(() => null, e => e);
      expect(err).not.toBeNull();
      expect(err.message).toMatch(/exceeded/);
      expect(child.kill).toHaveBeenCalledWith('SIGKILL');
    });
  });

  describe('streamEntries', () => {
    const reader = require(MODULE_PATH);

    test('emits parsed data + completes on exit', async () => {
      const child = new EventEmitter();
      child.stdout = new EventEmitter();
      child.stderr = new EventEmitter();
      child.kill = jest.fn();

      process.nextTick(() => {
        child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n'));
        child.emit('exit', 0, null);
      });

      const seen = [];
      const execSpy = jest.fn().mockReturnValue(child);
      reader.streamEntries({ unit: 'caddy' }, {
        exec: execSpy,
        onData: (e) => seen.push(e),
        onError: () => {},
      });
      // Drain microtasks so the nextTick callback fires.
      await new Promise((r) => setTimeout(r, 30));
      expect(execSpy).toHaveBeenCalledTimes(1);
      expect(seen.length).toBeGreaterThanOrEqual(1);
      expect(seen[0].unit).toBe('caddy');
      expect(seen[0].text).toBe('hello');
    });

    test('rejects bad unit before opening stream', () => {
      expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} }))
        .toThrow(/not in allow-list/);
    });
  });
});