import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createBrowserRecorder } from './browser-recorder.js';
import { logger } from '../logger.js';

describe('browser-recorder', () => {
  let root: string;
  beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'br-')); });
  afterEach(() => { rmSync(root, { recursive: true, force: true }); });

  // 1. enable then record then bufferSize → 1
  it('bufferSize is 1 after enable + record', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'my-session');
    r.record('t1', { type: 'goto', url: 'https://example.com' });
    expect(r.bufferSize('t1')).toBe(1);
  });

  // 2. record without prior enable is a no-op
  it('record without enable is a no-op', () => {
    const r = createBrowserRecorder();
    expect(() => r.record('t1', { type: 'click', selector: '#btn' })).not.toThrow();
    expect(r.bufferSize('t1')).toBe(0);
  });

  // 3. record stamps ts as a parseable ISO string
  it('record stamps ts as a parseable ISO string', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'sess');
    const before = Date.now();
    r.record('t1', { type: 'click', selector: '#btn' });
    const after = Date.now();
    // Access the stored action via flush output
    const path = r.flush('t1', root, 'owner1');
    const data = JSON.parse(readFileSync(path!, 'utf-8'));
    const ts = data.actions[0].ts as string;
    const parsed = new Date(ts).getTime();
    expect(parsed).toBeGreaterThanOrEqual(before);
    expect(parsed).toBeLessThanOrEqual(after + 100); // small margin for slow machines
  });

  // 4. recordTo returns the label after enable, null without enable
  it('recordTo returns label after enable and null before', () => {
    const r = createBrowserRecorder();
    expect(r.recordTo('t1')).toBeNull();
    r.enable('t1', 'label-abc');
    expect(r.recordTo('t1')).toBe('label-abc');
  });

  // 5. flush writes the expected JSON file with the expected shape
  it('flush writes a valid JSON file with correct shape', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'session-1');
    r.record('t1', { type: 'goto', url: 'https://example.com' });
    r.record('t1', { type: 'click', selector: '#btn', originalRef: 'e3' });
    const path = r.flush('t1', root, 'owner1');
    expect(path).not.toBeNull();
    expect(existsSync(path!)).toBe(true);
    const data = JSON.parse(readFileSync(path!, 'utf-8'));
    expect(data.recordTo).toBe('session-1');
    expect(typeof data.capturedAt).toBe('string');
    expect(new Date(data.capturedAt).getTime()).toBeGreaterThan(0);
    expect(Array.isArray(data.actions)).toBe(true);
    expect(data.actions).toHaveLength(2);
    expect(data.actions[0].type).toBe('goto');
    expect(data.actions[0].url).toBe('https://example.com');
    expect(typeof data.actions[0].ts).toBe('string');
    expect(data.actions[1].type).toBe('click');
    expect(data.actions[1].originalRef).toBe('e3');
    // Verify it's located at the expected path under recordings/
    expect(path).toBe(join(root, 'owner1', 'recordings', 'session-1.json'));
  });

  // 6. flush idempotency — second flush returns null
  it('second flush immediately after returns null', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'sess');
    r.record('t1', { type: 'wait', ms: 500 });
    const first = r.flush('t1', root, 'owner1');
    expect(first).not.toBeNull();
    const second = r.flush('t1', root, 'owner1');
    expect(second).toBeNull();
  });

  // 7. flush returns null when buffer is empty (no file created)
  it('flush returns null with empty buffer and creates no file', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'sess');
    // no records
    const path = r.flush('t1', root, 'owner1');
    expect(path).toBeNull();
    // The recordings dir may or may not exist, but the json file must not
    const would_be_path = join(root, 'owner1', 'recordings', 'sess.json');
    expect(existsSync(would_be_path)).toBe(false);
  });

  // 8. cancel clears the buffer (subsequent flush returns null)
  it('cancel clears buffer so subsequent flush returns null', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'sess');
    r.record('t1', { type: 'click', selector: '#x' });
    r.cancel('t1');
    expect(r.bufferSize('t1')).toBe(0);
    const path = r.flush('t1', root, 'owner1');
    expect(path).toBeNull();
  });

  // 9. cancel idempotency — safe to call multiple times
  it('cancel is idempotent', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'sess');
    r.record('t1', { type: 'click', selector: '#x' });
    expect(() => {
      r.cancel('t1');
      r.cancel('t1');
      r.cancel('t1');
    }).not.toThrow();
  });

  // ── recordTo validation tests ─────────────────────────────────────────────────

  // 9b. enable with traversal name '../escape' is a no-op
  it('enable with traversal recordTo "../escape" is a no-op', () => {
    const r = createBrowserRecorder();
    r.enable('t1', '../escape');
    expect(r.recordTo('t1')).toBeNull();
  });

  // 9c. enable with spaces in recordTo is rejected
  it('enable with recordTo containing spaces is a no-op', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'with spaces');
    expect(r.recordTo('t1')).toBeNull();
  });

  // 9d. enable with name longer than 128 chars is rejected
  it('enable with recordTo longer than 128 chars is a no-op', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'a'.repeat(129));
    expect(r.recordTo('t1')).toBeNull();
  });

  // ── Buffer cap tests ──────────────────────────────────────────────────────────

  // 9e. record 5001 actions; buffer stays at 5000
  it('buffer is capped at 5000 actions', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'my-session');
    for (let i = 0; i < 5001; i++) {
      r.record('t1', { type: 'click', selector: `#btn-${i}` });
    }
    expect(r.bufferSize('t1')).toBe(5000);
  });

  // 10. Two different taskIds have independent buffers
  it('two different taskIds have independent buffers', () => {
    const r = createBrowserRecorder();
    r.enable('taskA', 'sess-a');
    r.enable('taskB', 'sess-b');
    r.record('taskA', { type: 'goto', url: 'https://a.com' });
    r.record('taskA', { type: 'click', selector: '#a' });
    r.record('taskB', { type: 'goto', url: 'https://b.com' });

    expect(r.bufferSize('taskA')).toBe(2);
    expect(r.bufferSize('taskB')).toBe(1);

    const pathA = r.flush('taskA', root, 'owner1');
    const pathB = r.flush('taskB', root, 'owner1');

    const dataA = JSON.parse(readFileSync(pathA!, 'utf-8'));
    const dataB = JSON.parse(readFileSync(pathB!, 'utf-8'));

    expect(dataA.actions).toHaveLength(2);
    expect(dataA.actions[0].url).toBe('https://a.com');

    expect(dataB.actions).toHaveLength(1);
    expect(dataB.actions[0].url).toBe('https://b.com');

    // Verify no cross-contamination: taskB has no actions from taskA
    expect(dataB.actions.some((a: { selector?: string }) => a.selector === '#a')).toBe(false);
  });

  // ── Fix 1: Per-action payload cap ──────────────────────────────────────────────

  it('truncates oversized string fields in recorded actions', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'rec');
    const huge = 'x'.repeat(20_000);
    r.record('t1', { type: 'fill', selector: huge, value: huge, frameChain: [] });
    expect(r.bufferSize('t1')).toBe(1);

    const path = r.flush('t1', root, 'owner1');
    expect(path).not.toBeNull();
    const data = JSON.parse(readFileSync(path!, 'utf-8'));
    const action = data.actions[0];

    // Verify selector is truncated and contains the notice
    expect(action.selector).toBeDefined();
    expect(action.selector.length).toBeLessThan(10_000); // Well under original 20k
    expect(action.selector).toContain('…[truncated from');

    // Verify value is also truncated
    expect(action.value).toBeDefined();
    expect(action.value.length).toBeLessThan(10_000);
    expect(action.value).toContain('…[truncated from');

    // Verify undefined fields are preserved as undefined
    expect(action.url).toBeUndefined();
  });

  // ── Fix 2: Invalid recordTo clears existing buffer ──────────────────────────────

  it('enable with invalid recordTo clears existing buffer', () => {
    const r = createBrowserRecorder();
    r.enable('t1', 'rec');
    r.record('t1', { type: 'goto', url: 'https://x.com', frameChain: [] });
    expect(r.bufferSize('t1')).toBe(1);
    expect(r.recordTo('t1')).toBe('rec');

    // Now enable with an invalid name
    r.enable('t1', '../escape');
    expect(r.recordTo('t1')).toBeNull();
    expect(r.bufferSize('t1')).toBe(0);

    // Verify flush returns null (buffer was cleared)
    const path = r.flush('t1', root, 'owner1');
    expect(path).toBeNull();
  });

  // ── Fix 3: warnedBufferCap cleanup ────────────────────────────────────────────

  it('cancel cleans up the cap-warn tracking across rearm cycles', () => {
    const r = createBrowserRecorder();
    const warnSpy = vi.spyOn(logger, 'warn');

    // Fill buffer to cap on first cycle
    r.enable('t1', 'sess');
    for (let i = 0; i < 5001; i++) {
      r.record('t1', { type: 'click', selector: `#btn-${i}` });
    }
    expect(r.bufferSize('t1')).toBe(5000);

    // Warning fires once
    const warnCount1 = warnSpy.mock.calls.filter((call) =>
      call[0]?.includes('reached 5000-action cap')
    ).length;
    expect(warnCount1).toBe(1);

    // Cancel clears both buffer and warn tracking
    r.cancel('t1');
    expect(r.bufferSize('t1')).toBe(0);

    // Re-enable and fill again
    r.enable('t1', 'sess');
    for (let i = 0; i < 5001; i++) {
      r.record('t1', { type: 'click', selector: `#btn-${i}` });
    }
    expect(r.bufferSize('t1')).toBe(5000);

    // Warning fires again (not suppressed because we cleaned up the tracking)
    const warnCount2 = warnSpy.mock.calls.filter((call) =>
      call[0]?.includes('reached 5000-action cap')
    ).length;
    expect(warnCount2).toBe(2);

    warnSpy.mockRestore();
  });
});
