import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { buildCryptoDiagReport, getCryptoDiagEntries, installCryptoDiagLog } from './cryptoDiagLog';

// installCryptoDiagLog() replaces console.warn/error with capturing wrappers that
// ALWAYS pass through to the original. Silence the originals here (before install
// captures them) so the ring-buffer test doesn't spam hundreds of lines into the
// test output — the capture logic itself still runs. The module's `entries` buffer
// is module-global and accumulates across tests in this file, so assertions use
// deltas or the absolute cap rather than assuming an empty buffer.
console.warn = () => undefined;
console.error = () => undefined;
installCryptoDiagLog();

const mockClient = (partial: Partial<Record<string, unknown>>): MatrixClient =>
  partial as unknown as MatrixClient;

test('captures a KE-signature line and ignores non-matching output', () => {
  const before = getCryptoDiagEntries().length;
  console.error('POST /keys/upload 400 M_UNKNOWN: One time key already exists');
  console.warn('just a normal warning with nothing to capture');
  const after = getCryptoDiagEntries();
  assert.equal(after.length, before + 1, 'only the matching line is captured');
  const last = after[after.length - 1];
  assert.equal(last.ke, 'KE-1');
  assert.equal(last.signature, 'already exists');
  assert.equal(last.level, 'error');
  assert.match(last.message, /already exists/);
  assert.match(last.ts, /^\d{4}-\d\d-\d\dT/); // ISO-8601 UTC
});

test('matches the first (tightest) signature when several apply', () => {
  const before = getCryptoDiagEntries().length;
  // Matches both KE-1 'already exists' (index 0) and KE-2 'MissingKey' (index 3);
  // find() returns the first, so the tightest/most-specific label is recorded.
  console.error('MissingKey: the session key already exists somehow');
  const e = getCryptoDiagEntries();
  assert.equal(e.length, before + 1);
  assert.equal(e[e.length - 1].ke, 'KE-1');
  assert.equal(e[e.length - 1].signature, 'already exists');
});

test('serializes Error and object args into the captured message', () => {
  const b1 = getCryptoDiagEntries().length;
  console.error(new Error('boom: io.element.call.encryption_keys arrived encrypted'));
  const afterErr = getCryptoDiagEntries();
  assert.equal(afterErr.length, b1 + 1);
  assert.match(afterErr[afterErr.length - 1].message, /^Error: boom/);
  assert.equal(afterErr[afterErr.length - 1].ke, 'KE-2');

  const b2 = getCryptoDiagEntries().length;
  console.warn('missing key at index', { index: 7 });
  const afterObj = getCryptoDiagEntries();
  assert.equal(afterObj.length, b2 + 1);
  assert.match(afterObj[afterObj.length - 1].message, /\{"index":7\}/);
  assert.equal(afterObj[afterObj.length - 1].ke, 'KE-2');
});

test('ring-buffers to at most 200 entries, evicting the oldest', () => {
  // Push well past the cap with uniquely-tagged KE-4 lines (matches /delayed event/i).
  for (let i = 0; i < 250; i += 1) {
    console.warn(`delayed event tag=${i};`);
  }
  const e = getCryptoDiagEntries();
  assert.equal(e.length, 200, 'buffer is capped at MAX_ENTRIES');
  assert.match(e[e.length - 1].message, /tag=249;/, 'newest is retained');
  assert.ok(!e.some((x) => x.message.includes('tag=0;')), 'the oldest pushes were evicted');
});

test('getCryptoDiagEntries returns a copy, not the live buffer', () => {
  const a = getCryptoDiagEntries();
  const b = getCryptoDiagEntries();
  assert.notEqual(a, b, 'each call returns a fresh array');
  const len = a.length;
  a.push({ ts: 'x', level: 'warn', ke: 'X', signature: 'x', message: 'x' });
  assert.equal(getCryptoDiagEntries().length, len, 'mutating the copy does not affect the buffer');
});

// NOTE: these run after the ring-buffer test has filled the buffer to its cap,
// so a capture evicts the oldest and length stays at 200 — assert on the NEWEST
// entry (always the line just pushed) rather than a length delta.
test('captures the KE-3 and KE-4 signatures', () => {
  console.error('DecryptionError: unable to decrypt event');
  const e1 = getCryptoDiagEntries();
  assert.equal(e1[e1.length - 1].ke, 'KE-3');
  assert.equal(e1[e1.length - 1].signature, 'DecryptionError');

  // underscore, not a space, so it matches the tighter 'update_delayed_event'
  // row (index 5) rather than the looser 'delayed event' row.
  console.warn('msc4157.update_delayed_event timed out');
  const e2 = getCryptoDiagEntries();
  assert.equal(e2[e2.length - 1].ke, 'KE-4');
  assert.equal(e2[e2.length - 1].signature, 'update_delayed_event');
});

test('serializes an unserializable (circular) arg via the String() fallback without throwing', () => {
  const circular: Record<string, unknown> = {};
  circular.self = circular; // JSON.stringify throws → stringifyArg falls back to String()
  // Pair with a matching string so the line is captured; the object exercises
  // the catch branch. This must not throw.
  console.error('DecryptionError from', circular);
  const e = getCryptoDiagEntries();
  assert.equal(e[e.length - 1].ke, 'KE-3');
  assert.match(e[e.length - 1].message, /\[object Object\]/);
});

test('installCryptoDiagLog is idempotent — a second call does not re-wrap console', () => {
  const wrappedWarn = console.warn;
  installCryptoDiagLog(); // guarded no-op
  // Reference unchanged ⇒ not re-wrapped ⇒ a line is captured once, not doubled.
  assert.equal(console.warn, wrappedWarn, 'console.warn is not re-wrapped');
  console.error('DecryptionError single-capture check');
  const e = getCryptoDiagEntries();
  assert.match(e[e.length - 1].message, /single-capture check/, 'still captured');
});

test('buildCryptoDiagReport captures client metadata in a fixed, PII-safe shape', () => {
  const mx = mockClient({
    getDeviceId: () => 'DEVICE123',
    getUserId: () => '@alice:example.org',
    getSyncState: () => 'SYNCING',
    getCrypto: () => ({}),
    getSdkVersion: () => '99.9.9',
  });
  const report = JSON.parse(buildCryptoDiagReport(mx));

  assert.equal(report.kind, 'lotus-crypto-diag');
  assert.equal(report.deviceId, 'DEVICE123');
  assert.equal(report.userId, '@alice:example.org');
  assert.equal(report.syncState, 'SYNCING');
  assert.equal(report.cryptoReady, true);
  assert.equal(report.sdkVersion, '99.9.9');
  assert.equal(report.maxEntries, 200);
  assert.equal(report.entryCount, getCryptoDiagEntries().length);

  const sum = Object.values<number>(report.countsByKe).reduce((a, b) => a + b, 0);
  assert.equal(sum, report.entryCount, 'countsByKe sums to entryCount');

  // Locked field set: nothing beyond these documented keys ever leaks.
  assert.deepEqual(Object.keys(report).sort(), [
    'countsByKe',
    'cryptoReady',
    'deviceId',
    'entries',
    'entryCount',
    'generatedAt',
    'kind',
    'maxEntries',
    'sdkVersion',
    'syncState',
    'userId',
  ]);
});

test('buildCryptoDiagReport degrades gracefully with no client', () => {
  const report = JSON.parse(buildCryptoDiagReport());
  assert.equal(report.deviceId, null);
  assert.equal(report.userId, null);
  assert.equal(report.syncState, null);
  assert.equal(report.cryptoReady, false);
  // sdkVersion falls back to the declared package.json pin — a version string.
  assert.equal(typeof report.sdkVersion, 'string');
  assert.ok(report.sdkVersion.length > 0);
});

test('sdkVersion falls back to the declared pin when the client getter throws', () => {
  const mx = mockClient({
    getDeviceId: () => null,
    getUserId: () => null,
    getSyncState: () => null,
    getCrypto: () => undefined,
    getSdkVersion: () => {
      throw new Error('not ready');
    },
  });
  const report = JSON.parse(buildCryptoDiagReport(mx));
  assert.equal(typeof report.sdkVersion, 'string');
  assert.ok(report.sdkVersion.length > 0);
});
