/**
 * Tests for AggregateError / .cause-chain diagnostic surfacing in
 * src/utils/logging.js writeErrorLog().
 *
 * Bug fixed: writeErrorLog previously emitted `error.message` alone.
 * AggregateError's `.message` is "" by spec, so a real aggregate (e.g.
 * `await Promise.any([fetch(...), fetch(...)])` or a multi-A DNS lookup
 * that times out) ended up in error.log as a single empty line:
 *
 *   [2026-08-18T06:49:03.345Z] [ERR] update:
 *   context: {"imageName":"ipfs/kubo:latest"}
 *
 * Operators couldn't tell why the check failed. This file asserts the
 * fixed behavior:
 *
 *  - AggregateError → emits a diagnostic block listing each sub-error's
 *    .code/.message.
 *  - Regular Error → no spurious diagnostic block.
 *  - Plain Error with `.code` (e.g. EPIPE) → head now shows
 *    `Error [EPIPE]: write EPIPE` (regression: `code` used to be dropped).
 *  - Error wrapping another Error via `.cause` → lists the cause.
 *  - AggregateError with mixed sub-errors (some Aggregate, some plain) →
 *    recurses correctly without losing any message.
 *  - Empty error.message is replaced with the error name so a bare
 *    AggregateError still renders something readable.
 *
 * log.error signature on this codebase: error(ctx, err, req?, extra?)
 * where extra is the JSON tail (and req is the Express req if any).
 */
const path = require('path');
const fs = require('fs').promises;
const os = require('os');

// Important: set LOG_DIR / ERROR_LOG_FILE BEFORE requiring logging.js so
// the per-test temp file is used as the log target.
const tmpDir = fs.realpathSync ? require('fs').realpathSync(os.tmpdir()) : os.tmpdir();
const TMP_LOG = path.join(tmpDir, `dashcaddy-error-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);

process.env.LOG_DIR = tmpDir;
process.env.ERROR_LOG_FILE = TMP_LOG;
process.env.AUDIT_LOG_FILE = path.join(tmpDir, 'unused-audit.json');

const { log } = require('../src/utils/logging');

async function readTail(n = 1) {
  const raw = await fs.readFile(TMP_LOG, 'utf8').catch(() => '');
  const sep = '\u2500'.repeat(72);
  const entries = raw.split(sep).map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean);
  return entries.slice(-n);
}

describe('writeErrorLog() — AggregateError + .cause diagnostics', () => {
  afterAll(async () => {
    try { await fs.unlink(TMP_LOG); } catch (_) {}
  });

  beforeEach(async () => {
    try { await fs.unlink(TMP_LOG); } catch (_) {}
  });

  test('plain Error: head contains name + message + stack', async () => {
    await log.error('plain', new Error('boom'), null, { requestId: 'r1' });
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] plain: Error: boom/);
    expect(entry).not.toMatch(/diagnostic:/); // no spurious diagnostic block
    expect(entry).toMatch(/\n {4}at /);       // stack preserved (lowercase `at` from V8)
    expect(entry).toMatch(/context: \{.*requestId.*"r1".*\}/);
  });

  test('plain Error with .code renders the code in the head (regression fix)', async () => {
    const e = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
    await log.error('stream', e);
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] stream: Error \[EPIPE\]: write EPIPE/);
    expect(entry).not.toMatch(/diagnostic:/);
  });

  test('custom Error subclass name is preserved in the head', async () => {
    class WidgetError extends Error {
      constructor(msg) { super(msg); this.name = 'WidgetError'; }
    }
    await log.error('sub', new WidgetError('blew up'));
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] sub: WidgetError: blew up/);
  });

  test('empty error.message falls back to the bare error.name (defensive)', async () => {
    const empty = new Error('');
    await log.error('empty', empty);
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] empty: Error$/m);
  });

  test('AggregateError with sub-errors emits a diagnostic block listing each cause', async () => {
    // Realistic shape: registry-1.docker.io multi-A lookup timeout returning
    // an AggregateError of ECONNREFUSED / Timeout / EAI_AGAIN sub-errors.
    const agg = new AggregateError(
      [
        Object.assign(new Error('connect ECONNREFUSED 157.240.20.50:443'), { code: 'ECONNREFUSED' }),
        Object.assign(new Error('connect ETIMEDOUT 157.240.21.50:443'), { code: 'ETIMEDOUT' }),
        Object.assign(new Error('getaddrinfo EAI_AGAIN registry-1.docker.io'), { code: 'EAI_AGAIN' }),
      ],
      ''
    );
    await log.error('update', agg, null, { imageName: 'ipfs/kubo:latest' });
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] update: AggregateError/);
    expect(entry).toMatch(/diagnostic:/);
    expect(entry).toMatch(/cause #1:/);
    expect(entry).toMatch(/cause #2:/);
    expect(entry).toMatch(/cause #3:/);
    expect(entry).toMatch(/Error \[ECONNREFUSED\]: connect ECONNREFUSED 157\.240\.20\.50:443/);
    expect(entry).toMatch(/Error \[ETIMEDOUT\]: connect ETIMEDOUT 157\.240\.21\.50:443/);
    expect(entry).toMatch(/Error \[EAI_AGAIN\]: getaddrinfo EAI_AGAIN registry-1\.docker\.io/);
    expect(entry).toMatch(/context: \{.*imageName.*"ipfs\/kubo:latest".*\}/);
    // No double header for AggregateError (we suppress the empty head line).
    expect(entry).not.toMatch(/diagnostic: AggregateError/);
  });

  test('Error with .cause emits a nested diagnostic block', async () => {
    const inner = new Error('TLS handshake failed');
    const outer = new Error('fetch failed', { cause: inner });
    await log.error('net', outer);
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] net: Error: fetch failed/);
    expect(entry).toMatch(/cause:/);
    expect(entry).toMatch(/Error: TLS handshake failed/);
  });

  test('nested AggregateError (sub-error is itself an Aggregate) recurses', async () => {
    const inner = new AggregateError([new Error('inner-A'), new Error('inner-B')], '');
    const outer = new AggregateError([new Error('outer-X'), inner], '');
    await log.error('rec', outer);
    const [entry] = await readTail();
    expect(entry).toMatch(/\[ERR\] rec: AggregateError/);
    expect(entry).toMatch(/cause #1:[\s\S]*Error: outer-X/);
    // inner is itself an Aggregate, so its child errors surface as "cause #N":
    expect(entry).toMatch(/inner-A/);
    expect(entry).toMatch(/inner-B/);
  });

  test('separator is appended after each entry (file-format invariant)', async () => {
    await log.error('sep', new Error('one'));
    await log.error('sep', new Error('two'));
    const raw = await fs.readFile(TMP_LOG, 'utf8');
    const sep = '\u2500'.repeat(72);
    // Count separator occurrences without reserved regex chars tripping us up.
    const re = new RegExp(sep.split('').map(c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')).join(''), 'g');
    const occurrences = (raw.match(re) || []).length;
    expect(occurrences).toBeGreaterThanOrEqual(2);
  });

  test('req field is still emitted when the calling site passes a request', async () => {
    const req = { method: 'POST', path: '/api/v1/widgets', ip: '10.0.0.5', get: () => 'curl/8', id: 'r-42' };
    await log.error('withreq', new Error('widget blew up'), req);
    const [entry] = await readTail();
    expect(entry).toMatch(/request: POST \/api\/v1\/widgets \| ip: 10\.0\.0\.5 \| ua: curl\/8 \| id: r-42/);
  });

  test('extra context JSON is still emitted after stack (regression)', async () => {
    await log.error('ctx', new Error('payload'), null, { operation: 'rotate', tenantId: 7 });
    const [entry] = await readTail();
    expect(entry).toMatch(/context: \{"operation":"rotate","tenantId":7\}/);
  });

  // Polish-grade hardening (per GLM round-1 B+ findings): cycle guard + depth cap.

  test('circular .cause references do not infinite-loop (cycle guard)', async () => {
    const a = new Error('top');
    const b = new Error('middle');
    const c = new Error('bottom');
    // c.cause = b would be normal; force a CYCLE by linking back to a.
    b.cause = a;
    a.cause = c;
    c.cause = a; // cycle: a <-> a
    await expect(log.error('cycle', a, null)).resolves.not.toThrow();
    const [entry] = await readTail();
    expect(entry).toMatch(/top/);
    expect(entry).toMatch(/cycle: same Error instance seen earlier/);
  });

  test('excessively deep .cause chains are truncated, not crashed (depth cap)', async () => {
    // Build a chain 50 deep ending in 'level-50' at the deepest; each layer
    // wraps the previous via .cause. log.error is called with the deepest
    // (outer) Error.
    let cur = new Error('level-1');
    for (let i = 2; i <= 50; i++) {
      const parent = new Error(`level-${i}`);
      parent.cause = cur;
      cur = parent;
    }
    await expect(log.error('deep', cur)).resolves.not.toThrow();
    const [entry] = await readTail();
    expect(entry).toMatch(/chain truncated at depth 16/);
    expect(entry).toMatch(/level-50/);   // the deepest/head shown in headline
    expect(entry).not.toMatch(/level-1/); // the leaf is too deep to render
  });

  test('circular `.errors` array (sub-error is itself in the parent) is bounded', async () => {
    const sub = new Error('shared sub-error');
    const agg = new AggregateError([sub, new Error('other')], '');
    // pathological: sub-Aggregate references the parent
    sub.errors = [agg];
    await expect(log.error('aggcycle', agg)).resolves.not.toThrow();
    const [entry] = await readTail();
    expect(entry).toMatch(/shared sub-error/);
    expect(entry).toMatch(/cycle: same Error instance seen earlier/);
  });
});
