import { describe, it, expect } from 'vitest';
import {
  sanitizeField,
  deriveClass,
  deriveLabel,
  makeTimelineEvent,
  appendEvent,
} from '../../src/session/timeline.js';
import type { TimelineEvent } from '../../src/types.js';

/**
 * N-timeline tests — src/session/timeline.ts
 *
 * Covers:
 *  - sanitizeField: control-char strip + truncation (SEC-H6)
 *  - deriveClass: hook name → semantic TimelineClass (§3.1)
 *  - deriveLabel: human-language label per event type
 *  - makeTimelineEvent: whitelist gate + sanitize + compose
 *  - appendEvent: immutability + bounded eviction (SEC-M8)
 */

// ── helpers ──────────────────────────────────────────────────────────────────

function makeEvent(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
  return {
    at: 1000,
    class: 'tool',
    label: 'ran Bash',
    ...overrides,
  };
}

// ── sanitizeField ─────────────────────────────────────────────────────────────

describe('sanitizeField', () => {
  it('returns the string unchanged when it has no control chars and is under max', () => {
    expect(sanitizeField('Bash')).toBe('Bash');
  });

  it('strips all control characters (0x00–0x1f)', () => {
    // NUL, LF, CR, ESC, tab should all be removed
    const input = '\x00Bash\x09script\x0a\x0d\x1b[1m';
    expect(sanitizeField(input)).toBe('Bashscript[1m');
  });

  it('strips null bytes embedded in the string', () => {
    expect(sanitizeField('he\x00llo')).toBe('hello');
  });

  it('truncates to the default max of 200 characters', () => {
    const long = 'a'.repeat(300);
    const result = sanitizeField(long);
    expect(result.length).toBe(200);
  });

  it('truncates to a custom max', () => {
    const result = sanitizeField('abcdef', 3);
    expect(result).toBe('abc');
  });

  it('handles an empty string', () => {
    expect(sanitizeField('')).toBe('');
  });

  it('does not truncate strings exactly at the max', () => {
    const exact = 'x'.repeat(200);
    expect(sanitizeField(exact).length).toBe(200);
  });

  it('strips control chars before truncating (strip-then-truncate)', () => {
    // 10 control chars + 5 regular chars; after strip only 5 remain → under max
    const withControls = '\x01'.repeat(10) + 'hello';
    expect(sanitizeField(withControls, 8)).toBe('hello');
  });
});

// ── deriveClass ───────────────────────────────────────────────────────────────

describe('deriveClass', () => {
  it('maps PreToolUse → "tool"', () => {
    expect(deriveClass('PreToolUse')).toBe('tool');
  });

  it('maps PostToolUse → "tool"', () => {
    expect(deriveClass('PostToolUse')).toBe('tool');
  });

  it('maps PermissionRequest → "waiting"', () => {
    expect(deriveClass('PermissionRequest')).toBe('waiting');
  });

  it('maps Notification → "waiting"', () => {
    expect(deriveClass('Notification')).toBe('waiting');
  });

  it('maps Stop → "done"', () => {
    expect(deriveClass('Stop')).toBe('done');
  });

  it('maps SessionEnd → "done"', () => {
    expect(deriveClass('SessionEnd')).toBe('done');
  });

  it('maps UserPromptSubmit → "user"', () => {
    expect(deriveClass('UserPromptSubmit')).toBe('user');
  });

  it('returns "tool" as a safe fallback for unknown event names', () => {
    expect(deriveClass('SomeUnknownEvent')).toBe('tool');
  });
});

// ── deriveLabel ───────────────────────────────────────────────────────────────

describe('deriveLabel', () => {
  describe('PreToolUse', () => {
    it('returns a label that includes the tool name when provided', () => {
      const label = deriveLabel('PreToolUse', 'Bash');
      expect(label).toContain('Bash');
    });

    it('returns a generic label when toolName is absent', () => {
      const label = deriveLabel('PreToolUse', undefined);
      expect(label.length).toBeGreaterThan(0);
    });
  });

  describe('PostToolUse', () => {
    it('returns a "ran" label for execution tools (Bash)', () => {
      const label = deriveLabel('PostToolUse', 'Bash');
      expect(label.toLowerCase()).toContain('bash');
    });

    it('returns an "edit" flavour label for Edit tool', () => {
      const label = deriveLabel('PostToolUse', 'Edit');
      // Should be something like "edited file" or "ran Edit" — contract just says it's human
      expect(label.length).toBeGreaterThan(0);
    });

    it('returns a generic label when toolName is absent', () => {
      const label = deriveLabel('PostToolUse', undefined);
      expect(label.length).toBeGreaterThan(0);
    });
  });

  describe('waiting events', () => {
    it('returns an approval-flavoured label for PermissionRequest', () => {
      const label = deriveLabel('PermissionRequest', undefined);
      expect(label.length).toBeGreaterThan(0);
      expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
    });

    it('returns an approval-flavoured label for Notification', () => {
      const label = deriveLabel('Notification', undefined);
      expect(label.toLowerCase()).toMatch(/wait|approv|permiss/);
    });
  });

  describe('done events', () => {
    it('returns a "done" label for Stop', () => {
      expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i);
    });

    it('returns a "done" label for SessionEnd', () => {
      expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i);
    });
  });

  describe('user input', () => {
    it('returns a user-input label for UserPromptSubmit', () => {
      const label = deriveLabel('UserPromptSubmit', undefined);
      expect(label.toLowerCase()).toMatch(/user|input|prompt/);
    });
  });
});

// ── makeTimelineEvent ─────────────────────────────────────────────────────────

describe('makeTimelineEvent', () => {
  it('returns null for an unknown event name (whitelist guard)', () => {
    expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull();
  });

  it('returns null for an empty string event name', () => {
    expect(makeTimelineEvent('', undefined, 1000)).toBeNull();
  });

  it('returns a TimelineEvent for PreToolUse', () => {
    const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234);
    expect(ev).not.toBeNull();
    expect(ev!.at).toBe(1234);
    expect(ev!.class).toBe('tool');
    expect(ev!.toolName).toBe('Bash');
    expect(ev!.label.length).toBeGreaterThan(0);
  });

  it('returns a TimelineEvent for PostToolUse', () => {
    const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('tool');
    expect(ev!.toolName).toBe('Bash');
  });

  it('returns a TimelineEvent for PermissionRequest', () => {
    const ev = makeTimelineEvent('PermissionRequest', undefined, 5000);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('waiting');
    expect(ev!.toolName).toBeUndefined();
  });

  it('returns a TimelineEvent for Stop', () => {
    const ev = makeTimelineEvent('Stop', undefined, 7000);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('done');
  });

  it('returns a TimelineEvent for SessionEnd', () => {
    const ev = makeTimelineEvent('SessionEnd', undefined, 8000);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('done');
  });

  it('returns a TimelineEvent for UserPromptSubmit', () => {
    const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('user');
  });

  it('returns a TimelineEvent for Notification', () => {
    const ev = makeTimelineEvent('Notification', undefined, 2000);
    expect(ev).not.toBeNull();
    expect(ev!.class).toBe('waiting');
  });

  it('sanitizes control characters in toolName (SEC-H6)', () => {
    const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000);
    expect(ev).not.toBeNull();
    expect(ev!.toolName).toBe('Bashscript');
  });

  it('truncates toolName to 200 characters (SEC-H6)', () => {
    const longName = 'a'.repeat(250);
    const ev = makeTimelineEvent('PostToolUse', longName, 1000);
    expect(ev).not.toBeNull();
    expect(ev!.toolName!.length).toBe(200);
  });

  it('omits toolName from the result when toolName is undefined', () => {
    const ev = makeTimelineEvent('Stop', undefined, 1000);
    expect(ev).not.toBeNull();
    expect(ev!.toolName).toBeUndefined();
  });
});

// ── appendEvent ───────────────────────────────────────────────────────────────

describe('appendEvent', () => {
  it('appends to an empty array', () => {
    const ev = makeEvent({ at: 100 });
    const result = appendEvent([], ev, 10);
    expect(result).toHaveLength(1);
    expect(result[0]).toBe(ev);
  });

  it('appends to a non-empty array', () => {
    const ev1 = makeEvent({ at: 100 });
    const ev2 = makeEvent({ at: 200 });
    const result = appendEvent([ev1], ev2, 10);
    expect(result).toHaveLength(2);
    expect(result[1]).toBe(ev2);
  });

  it('does not mutate the input array', () => {
    const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
    const ev = makeEvent({ at: 2 });
    appendEvent(original, ev, 10);
    expect(original).toHaveLength(1);
  });

  it('returns a new array reference (immutability)', () => {
    const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })];
    const ev = makeEvent({ at: 2 });
    const result = appendEvent(original, ev, 10);
    expect(result).not.toBe(original);
  });

  it('evicts the oldest events when length exceeds maxLen', () => {
    const events: readonly TimelineEvent[] = [
      makeEvent({ at: 1 }),
      makeEvent({ at: 2 }),
      makeEvent({ at: 3 }),
    ];
    const ev = makeEvent({ at: 4 });
    // maxLen = 3, so appending a 4th must evict the oldest (at=1)
    const result = appendEvent(events, ev, 3);
    expect(result).toHaveLength(3);
    expect(result[0]!.at).toBe(2);
    expect(result[2]!.at).toBe(4);
  });

  it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => {
    const MAX = 5;
    let events: readonly TimelineEvent[] = [];
    for (let i = 0; i < MAX + 10; i++) {
      events = appendEvent(events, makeEvent({ at: i }), MAX);
    }
    expect(events.length).toBeLessThanOrEqual(MAX);
  });

  it('keeps only the newest events when trimming (oldest are evicted first)', () => {
    const MAX = 3;
    let events: readonly TimelineEvent[] = [];
    for (let i = 0; i < 10; i++) {
      events = appendEvent(events, makeEvent({ at: i }), MAX);
    }
    // Should have the last 3 events: at = 7, 8, 9
    expect(events.map((e) => e.at)).toEqual([7, 8, 9]);
  });

  it('handles maxLen of 1 (only the newest event survives)', () => {
    const ev1 = makeEvent({ at: 1 });
    const ev2 = makeEvent({ at: 2 });
    const result = appendEvent([ev1], ev2, 1);
    expect(result).toHaveLength(1);
    expect(result[0]!.at).toBe(2);
  });
});

// ── integration: each TimelineClass has at least one example ─────────────────

describe('TimelineClass coverage (AC-A4.4)', () => {
  const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [
    { event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' },
    { event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' },
    { event: 'PermissionRequest', expectedClass: 'waiting' },
    { event: 'Notification', expectedClass: 'waiting' },
    { event: 'Stop', expectedClass: 'done' },
    { event: 'SessionEnd', expectedClass: 'done' },
    { event: 'UserPromptSubmit', expectedClass: 'user' },
  ];

  for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) {
    it(`${event} → class '${expectedClass}'`, () => {
      const ev = makeTimelineEvent(event, toolName, Date.now());
      expect(ev).not.toBeNull();
      expect(ev!.class).toBe(expectedClass);
    });
  }
});
