import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync, chmodSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  createFileEventLogger,
  NoopEventLogger,
  redactString,
  sanitizePayload,
  parseEventLine,
  EVENT_LOG_FILE,
  EVENT_LOG_VERSION,
  EVENT_MAX_EVENT_BYTES,
  EVENT_MAX_STRING_BYTES,
  type EventBase,
} from './event-log.js';

describe('redactString', () => {
  it('redacts password / api_key / secret / token / bearer / authorization patterns', () => {
    expect(redactString('admin password=hunter2 logged in')).toContain('password=[redacted]');
    expect(redactString('config.api_key = sk-12345')).toContain('api_key = [redacted]');
    expect(redactString('API_KEY: abc')).toContain('API_KEY: [redacted]');
    expect(redactString('secret: deadbeef')).toContain('secret: [redacted]');
    expect(redactString('auth bearer xyz.abc.123')).toContain('bearer [redacted]');
    expect(redactString('Authorization: Token foo')).toContain('Authorization: [redacted]');
    expect(redactString('the token = "abc.def.ghi"')).toContain('token = [redacted]');
  });

  it('leaves benign strings alone', () => {
    expect(redactString('foo bar baz')).toBe('foo bar baz');
    expect(redactString('the password word is fine without =')).toBe('the password word is fine without =');
  });
});

describe('sanitizePayload', () => {
  it('redacts strings inside nested objects + arrays', () => {
    const out = sanitizePayload({
      level1: {
        level2: ['x', 'password=hunter2', { secret: 'y' }],
      },
    });
    expect(JSON.stringify(out)).toContain('[redacted]');
  });

  it('truncates oversized strings to ~EVENT_MAX_STRING_BYTES', () => {
    const big = 'X'.repeat(EVENT_MAX_STRING_BYTES * 2);
    const out = sanitizePayload({ note: big }) as { note: string };
    expect(out.note.length).toBeLessThan(big.length);
    expect(out.note).toContain('[truncated,');
  });

  it('handles circular references without throwing', () => {
    const obj: Record<string, unknown> = { a: 1 };
    obj.self = obj;
    const out = sanitizePayload(obj) as { a: number; self: string };
    expect(out.a).toBe(1);
    expect(out.self).toBe('[circular]');
  });

  it('drops functions / symbols silently', () => {
    const out = sanitizePayload({ a: 1, fn: () => 0, sym: Symbol('x') }) as Record<string, unknown>;
    expect(out).toEqual({ a: 1 });
  });
});

describe('FileEventLogger', () => {
  let workspace: string;

  beforeEach(() => {
    workspace = mkdtempSync(join(tmpdir(), 'event-log-test-'));
  });

  afterEach(() => {
    rmSync(workspace, { recursive: true, force: true });
  });

  it('writes a v1 JSONL line with required fields', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    const id = log.emit('memory_add', { entryType: 'fact', claim: 'X is Y' });
    expect(id).toMatch(/^[0-9a-f-]+$/i);

    const raw = readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8');
    const line = raw.trim().split('\n')[0]!;
    const parsed = JSON.parse(line) as Record<string, unknown>;
    expect(parsed.v).toBe(EVENT_LOG_VERSION);
    expect(parsed.runId).toBe('run-1');
    expect(parsed.seq).toBe(1);
    expect(parsed.kind).toBe('memory_add');
    expect(parsed.eventId).toBe(id);
    expect(typeof parsed.ts).toBe('string');
  });

  it('seq increments monotonically across emits', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    log.emit('a', {});
    log.emit('b', {});
    log.emit('c', {});
    const lines = readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim().split('\n');
    const seqs = lines.map((l) => (JSON.parse(l) as EventBase).seq);
    expect(seqs).toEqual([1, 2, 3]);
  });

  it('redacts secrets in payload before writing to disk', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    log.emit('tool_call', {
      tool: 'Bash',
      args: { command: 'curl -H "Authorization: Bearer shhh" https://example.com' },
    });
    const raw = readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8');
    expect(raw).not.toContain('shhh');
    expect(raw).toContain('[redacted]');
  });

  it('truncates per-string fields to fit EVENT_MAX_STRING_BYTES', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    const huge = 'A'.repeat(EVENT_MAX_STRING_BYTES * 3);
    log.emit('tool_result', { tool: 'Read', output: huge });
    const raw = readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8');
    const parsed = JSON.parse(raw.trim()) as EventBase;
    const payload = parsed.payload as { output: string };
    expect(payload.output.length).toBeLessThan(huge.length);
    expect(payload.output).toContain('[truncated,');
    expect(Buffer.byteLength(raw, 'utf-8')).toBeLessThanOrEqual(EVENT_MAX_EVENT_BYTES + 16); // + newline
  });

  it('passes movement / iteration / correlationId / llmToolCallId through', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    const corr = log.startCorrelation();
    log.emit('tool_call', { tool: 'Read' }, {
      movement: 'investigate',
      iteration: 3,
      correlationId: corr,
      llmToolCallId: 'r-abc',
    });
    const parsed = JSON.parse(readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim()) as EventBase;
    expect(parsed.movement).toBe('investigate');
    expect(parsed.iteration).toBe(3);
    expect(parsed.correlationId).toBe(corr);
    expect(parsed.llmToolCallId).toBe('r-abc');
  });

  it('child() inherits movement / iteration scope', () => {
    const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
    const child = log.child({ movement: 'plan', iteration: 5 });
    child.emit('memory_add', {});
    const parsed = JSON.parse(readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim()) as EventBase;
    expect(parsed.movement).toBe('plan');
    expect(parsed.iteration).toBe(5);
  });

  it('does NOT throw when the workspace directory is not writable (failure isolation)', () => {
    // Make the workspace read-only so appendFileSync fails.
    chmodSync(workspace, 0o555);
    try {
      const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
      // Should not throw, even after multiple failures.
      expect(() => {
        for (let i = 0; i < 10; i++) log.emit('movement_start', { i });
      }).not.toThrow();
      // After MAX_CONSECUTIVE_FAILURES it auto-degrades.
      expect(log.describe().degraded).toBe(true);
    } finally {
      chmodSync(workspace, 0o755);
    }
  });

  it('NoopEventLogger no-ops everything safely', () => {
    const log = new NoopEventLogger();
    expect(log.emit('movement_start', {})).toBe('noop');
    expect(log.startCorrelation()).toBe('noop');
    expect(log.child({}).emit('x', {})).toBe('noop');
    expect(log.describe().runId).toBe('noop');
  });
});

describe('parseEventLine', () => {
  it('parses a valid v1 line', () => {
    const event: EventBase = {
      v: 1,
      ts: '2026-05-02T00:00:00.000Z',
      seq: 1,
      eventId: 'e-1',
      runId: 'r-1',
      kind: 'movement_start',
      payload: {},
    };
    const out = parseEventLine(JSON.stringify(event));
    expect(out.kind).toBe('ok');
  });

  it('returns unknown_version for v != 1', () => {
    const out = parseEventLine(JSON.stringify({ v: 99, ts: 't', seq: 1, eventId: 'x', runId: 'r', kind: 'k' }));
    expect(out.kind).toBe('unknown_version');
  });

  it('returns invalid for missing required fields', () => {
    const out = parseEventLine(JSON.stringify({ v: 1, ts: 't', kind: 'k' }));
    expect(out.kind).toBe('invalid');
    if (out.kind === 'invalid') {
      expect(out.reason).toMatch(/missing required field/);
    }
  });

  it('returns invalid for non-JSON', () => {
    const out = parseEventLine('{not json');
    expect(out.kind).toBe('invalid');
  });
});
