import { describe, expect, it, vi } from 'vitest';
import { EventBus, ScopedEventBus } from '../../src/kernel/events.js';

describe('ScopedEventBus', () => {
  it('tracks listener count via scopedListenerCount', () => {
    const bus = new ScopedEventBus();
    expect(bus.scopedListenerCount).toBe(0);
    bus.on('session.started', vi.fn());
    expect(bus.scopedListenerCount).toBe(1);
    bus.on('tool.executed', vi.fn());
    expect(bus.scopedListenerCount).toBe(2);
    bus.off('session.started', vi.fn());
    bus.off('tool.executed', vi.fn());
  });

  it('on() returns unsubscribe that is also tracked', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    bus.on('session.started', fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus.emit('session.started', { id: '1' });
    expect(fn).toHaveBeenCalled();
  });

  it('once() returns tracked unsubscribe and fully cleans up on fire', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    const off = bus.once('session.started', fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus.emit('session.started', { id: '1' });
    expect(fn).toHaveBeenCalledTimes(1);
    // After the once-listener fires, BOTH the underlying EventBus listener
    // and the ScopedEventBus tracking entry are removed. scopedListenerCount
    // returns to its pre-call value so the public metric is honest.
    expect(bus.scopedListenerCount).toBe(0);
    // The returned off is now a no-op: the underlying listener is already
    // gone, and the tracking entry was deleted in the wrapper.
    expect(() => off()).not.toThrow();
    expect(bus.scopedListenerCount).toBe(0);
  });

  it('once() tracking entry is removed BEFORE the user fn runs (mid-fire introspection sees the post-state)', () => {
    const bus = new ScopedEventBus();
    let countInsideHandler = -1;
    bus.once('session.started', () => {
      countInsideHandler = bus.scopedListenerCount;
    });
    bus.emit('session.started', { id: '1' });
    expect(countInsideHandler).toBe(0);
  });

  it('once() with explicit off before emit also cleans up the tracking entry', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    const off = bus.once('session.started', fn);
    expect(bus.scopedListenerCount).toBe(1);
    off();
    expect(bus.scopedListenerCount).toBe(0);
    bus.emit('session.started', { id: '1' });
    expect(fn).not.toHaveBeenCalled();
  });

  it('teardown() removes all tracked listeners', () => {
    const bus = new ScopedEventBus();
    const a = vi.fn();
    const b = vi.fn();
    const offA = bus.on('session.started', a);
    const offB = bus.on('tool.executed', b);
    expect(bus.scopedListenerCount).toBe(2);

    bus.teardown();

    expect(bus.scopedListenerCount).toBe(0);
    bus.emit('session.started', { id: '1' });
    bus.emit('tool.executed', { name: 'x', durationMs: 0, ok: true });
    expect(a).not.toHaveBeenCalled();
    expect(b).not.toHaveBeenCalled();
  });

  it('teardown() is idempotent', () => {
    const bus = new ScopedEventBus();
    bus.on('session.started', vi.fn());
    bus.teardown();
    expect(() => bus.teardown()).not.toThrow();
  });

  it('teardown() also calls clear() on the underlying bus', () => {
    const bus = new ScopedEventBus();
    bus.on('session.started', vi.fn());
    bus.teardown();
    // clear() would have removed all underlying listeners too
    bus.emit('session.started', { id: '1' });
    // if clear() was called, no listeners fire
  });

  it('[Symbol.dispose] aliases teardown()', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    bus.on('session.started', fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus[Symbol.dispose]();
    expect(bus.scopedListenerCount).toBe(0);
  });

  it('onPattern() registers and can be torn down', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    bus.onPattern('tool.*', fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus.teardown();
    expect(bus.scopedListenerCount).toBe(0);
  });

  it('onRegex() registers and can be torn down', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    bus.onRegex(/^session\./, fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus.teardown();
    expect(bus.scopedListenerCount).toBe(0);
  });

  it('onAny() registers and can be torn down', () => {
    const bus = new ScopedEventBus();
    const fn = vi.fn();
    bus.onAny(fn);
    expect(bus.scopedListenerCount).toBe(1);
    bus.teardown();
    expect(bus.scopedListenerCount).toBe(0);
  });

  it('scoped unsubscribe removes from tracking but does not affect other listeners', () => {
    const bus = new ScopedEventBus();
    const a = vi.fn();
    const b = vi.fn();
    const offA = bus.on('session.started', a);
    bus.on('session.started', b);
    expect(bus.scopedListenerCount).toBe(2);

    offA(); // manually unsubscribe a

    expect(bus.scopedListenerCount).toBe(1);
    bus.emit('session.started', { id: '1' });
    expect(a).not.toHaveBeenCalled();
    expect(b).toHaveBeenCalled();
  });
});

describe('EventBus', () => {
  it('emits to subscribers', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    bus.on('session.started', fn);
    bus.emit('session.started', { id: 'abc' });
    expect(fn).toHaveBeenCalledWith({ id: 'abc' });
  });

  it('emits to multiple event types', () => {
    const bus = new EventBus();
    const a = vi.fn();
    const b = vi.fn();
    bus.on('session.started', a);
    bus.on('tool.executed', b);
    bus.emit('session.started', { id: '1' });
    bus.emit('tool.executed', { name: 'test', durationMs: 100, ok: true });
    expect(a).toHaveBeenCalledTimes(1);
    expect(b).toHaveBeenCalledTimes(1);
  });

  it('multiple subscribers each receive', () => {
    const bus = new EventBus();
    const a = vi.fn();
    const b = vi.fn();
    bus.on('session.started', a);
    bus.on('session.started', b);
    bus.emit('session.started', { id: '1' });
    expect(a).toHaveBeenCalled();
    expect(b).toHaveBeenCalled();
  });

  it('off unsubscribes', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    bus.on('session.started', fn);
    bus.off('session.started', fn);
    bus.emit('session.started', { id: '1' });
    expect(fn).not.toHaveBeenCalled();
  });

  it('listener errors are isolated', () => {
    const bus = new EventBus();
    bus.setLogger({ error: () => undefined });
    const good = vi.fn();
    bus.on('session.started', () => {
      throw new Error('bad');
    });
    bus.on('session.started', good);
    bus.emit('session.started', { id: '1' });
    expect(good).toHaveBeenCalled();
  });

  it('unsubscribe returned from on()', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    const off = bus.on('session.started', fn);
    off();
    bus.emit('session.started', { id: '1' });
    expect(fn).not.toHaveBeenCalled();
  });

  describe('once', () => {
    it('fires listener only once', () => {
      const bus = new EventBus();
      const fn = vi.fn();
      bus.once('session.started', fn);
      bus.emit('session.started', { id: '1' });
      bus.emit('session.started', { id: '2' });
      expect(fn).toHaveBeenCalledTimes(1);
      expect(fn).toHaveBeenCalledWith({ id: '1' });
    });

    it('returns unsubscribe that prevents firing', () => {
      const bus = new EventBus();
      const fn = vi.fn();
      const off = bus.once('session.started', fn);
      off();
      bus.emit('session.started', { id: '1' });
      expect(fn).not.toHaveBeenCalled();
    });

    it('unsubscribes after first call even without explicit off', () => {
      const bus = new EventBus();
      const fn = vi.fn();
      bus.once('iteration.completed', fn);
      bus.emit('iteration.completed', { ctx: null as any, index: 1 });
      bus.emit('iteration.completed', { ctx: null as any, index: 2 });
      expect(fn).toHaveBeenCalledTimes(1);
    });

    it('unsubscribes from correct event only', () => {
      const bus = new EventBus();
      const fn = vi.fn();
      bus.once('session.started', fn);
      bus.emit('session.started', { id: '1' });
      // other event type should not trigger
      bus.emit('tool.executed', { name: 'x', durationMs: 0, ok: true });
      expect(fn).toHaveBeenCalledTimes(1);
    });
  });

  describe('clear', () => {
    it('removes all listeners', () => {
      const bus = new EventBus();
      const a = vi.fn();
      const b = vi.fn();
      bus.on('session.started', a);
      bus.on('tool.executed', b);
      bus.clear();
      bus.emit('session.started', { id: '1' });
      bus.emit('tool.executed', { name: 'x', durationMs: 0, ok: true });
      expect(a).not.toHaveBeenCalled();
      expect(b).not.toHaveBeenCalled();
    });

    it('clearing empty bus is fine', () => {
      const bus = new EventBus();
      expect(() => bus.clear()).not.toThrow();
    });
  });

  it('does not throw when emitting to empty event', () => {
    const bus = new EventBus();
    expect(() => bus.emit('error', { err: new Error('test'), phase: 'test' })).not.toThrow();
  });

  it('setLogger accepts logger', () => {
    const bus = new EventBus();
    expect(() => bus.setLogger({ error: () => {} })).not.toThrow();
  });

  it('onAny() is an alias for onPattern("*") and receives all events', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    bus.onAny(fn);
    bus.emit('session.started', { id: '1' });
    bus.emit('tool.executed', { name: 'x', durationMs: 0, ok: true });
    bus.emit('error', { err: new Error('boom'), phase: 'test' });
    expect(fn).toHaveBeenCalledTimes(3);
    expect(fn).toHaveBeenCalledWith('session.started', { id: '1' });
    expect(fn).toHaveBeenCalledWith('tool.executed', { name: 'x', durationMs: 0, ok: true });
  });

  it('onAny() returns working unsubscribe', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    const off = bus.onAny(fn);
    bus.emit('session.started', { id: '1' });
    expect(fn).toHaveBeenCalledTimes(1);
    off();
    bus.emit('session.started', { id: '2' });
    expect(fn).toHaveBeenCalledTimes(1);
  });

  it('emits error event with error and phase', () => {
    const bus = new EventBus();
    const fn = vi.fn();
    bus.on('error', fn);
    const err = new Error('boom');
    bus.emit('error', { err, phase: 'execution' });
    expect(fn).toHaveBeenCalledWith({ err, phase: 'execution' });
  });

  it('emit() snapshots wildcards so a pattern added mid-emit does not fire on the same emit', () => {
    // Regression guard: ECMA leaves mid-iteration array mutation
    // under-specified, so emit() takes a snapshot to make the behavior
    // engine-portable. The new pattern still fires on the NEXT emit.
    const bus = new EventBus();
    const late = vi.fn();
    bus.onPattern('*', () => {
      bus.onPattern('*', late);
    });
    bus.emit('session.started', { id: '1' });
    expect(late).not.toHaveBeenCalled();
    // The pattern IS now registered — the next emit should fire it.
    bus.emit('session.started', { id: '2' });
    expect(late).toHaveBeenCalledTimes(1);
  });

  it('emit() removes a wildcard mid-iteration without skipping the unvisited wildcards', () => {
    // Regression guard: if emit() didn't snapshot, removing an entry
    // mid-iteration could change which subsequent entries get visited.
    // The snapshot guarantees that every wildcard that was registered
    // BEFORE emit() runs gets a chance to fire exactly once.
    const bus = new EventBus();
    const order: string[] = [];
    const offB = bus.onPattern('*', () => {
      order.push('b');
      offB();
    });
    bus.onPattern('*', () => order.push('c'));
    bus.emit('session.started', { id: '1' });
    expect(order).toEqual(['b', 'c']);
  });
});
