/**
 * @license
 * Copyright 2026 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
  CoreEventEmitter,
  CoreEvent,
  coreEvents,
  type CoreEvents,
  type UserFeedbackPayload,
  type McpProgressPayload,
} from './events.js';

vi.mock('./debugLogger.js', () => ({
  debugLogger: { log: vi.fn() },
}));

describe('CoreEventEmitter', () => {
  let events: CoreEventEmitter;

  beforeEach(() => {
    events = new CoreEventEmitter();
  });

  it('should emit feedback immediately when a listener is present', () => {
    const listener = vi.fn();
    events.on(CoreEvent.UserFeedback, listener);

    const payload = {
      severity: 'info' as const,
      message: 'Test message',
    };

    events.emitFeedback(payload.severity, payload.message);

    expect(listener).toHaveBeenCalledTimes(1);
    expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
  });

  it('should buffer feedback when no listener is present', () => {
    const listener = vi.fn();
    const payload = {
      severity: 'warning' as const,
      message: 'Buffered message',
    };

    // Emit while no listeners attached
    events.emitFeedback(payload.severity, payload.message);
    expect(listener).not.toHaveBeenCalled();

    // Attach listener and drain
    events.on(CoreEvent.UserFeedback, listener);
    events.drainBacklogs();

    expect(listener).toHaveBeenCalledTimes(1);
    expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
  });

  it('should respect the backlog size limit and maintain FIFO order', () => {
    const listener = vi.fn();
    const MAX_BACKLOG_SIZE = 10000;

    for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) {
      events.emitFeedback('info', `Message ${i}`);
    }

    events.on(CoreEvent.UserFeedback, listener);
    events.drainBacklogs();

    expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
    // Verify strictly that the FIRST call was Message 10 (0-9 dropped)
    expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Message 10' });
    // Verify strictly that the LAST call was Message 109
    expect(listener.mock.lastCall?.[0]).toMatchObject({
      message: `Message ${MAX_BACKLOG_SIZE + 9}`,
    });
  });

  it('should clear the backlog after draining', () => {
    const listener = vi.fn();
    events.emitFeedback('error', 'Test error');

    events.on(CoreEvent.UserFeedback, listener);
    events.drainBacklogs();
    expect(listener).toHaveBeenCalledTimes(1);

    listener.mockClear();
    events.drainBacklogs();
    expect(listener).not.toHaveBeenCalled();
  });

  it('should include optional error object in payload', () => {
    const listener = vi.fn();
    events.on(CoreEvent.UserFeedback, listener);

    const error = new Error('Original error');
    events.emitFeedback('error', 'Something went wrong', error);

    expect(listener).toHaveBeenCalledWith(
      expect.objectContaining({
        severity: 'error',
        message: 'Something went wrong',
        error,
      }),
    );
  });

  it('should handle multiple listeners correctly', () => {
    const listenerA = vi.fn();
    const listenerB = vi.fn();

    events.on(CoreEvent.UserFeedback, listenerA);
    events.on(CoreEvent.UserFeedback, listenerB);

    events.emitFeedback('info', 'Broadcast message');

    expect(listenerA).toHaveBeenCalledTimes(1);
    expect(listenerB).toHaveBeenCalledTimes(1);
  });

  it('should stop receiving events after off() is called', () => {
    const listener = vi.fn();
    events.on(CoreEvent.UserFeedback, listener);

    events.emitFeedback('info', 'First message');
    expect(listener).toHaveBeenCalledTimes(1);

    events.off(CoreEvent.UserFeedback, listener);
    events.emitFeedback('info', 'Second message');
    expect(listener).toHaveBeenCalledTimes(1); // Still 1
  });

  it('should handle re-entrant feedback emission during draining safely', () => {
    events.emitFeedback('info', 'Buffered 1');
    events.emitFeedback('info', 'Buffered 2');

    const listener = vi.fn((payload: UserFeedbackPayload) => {
      // When 'Buffered 1' is received, immediately emit another event.
      if (payload.message === 'Buffered 1') {
        events.emitFeedback('warning', 'Re-entrant message');
      }
    });

    events.on(CoreEvent.UserFeedback, listener);
    events.drainBacklogs();

    // Expectation with atomic snapshot:
    // 1. loop starts with ['Buffered 1', 'Buffered 2']
    // 2. emits 'Buffered 1'
    // 3. listener fires for 'Buffered 1', calls emitFeedback('Re-entrant')
    // 4. emitFeedback sees listener attached, emits 'Re-entrant' synchronously
    // 5. listener fires for 'Re-entrant'
    // 6. loop continues, emits 'Buffered 2'
    // 7. listener fires for 'Buffered 2'

    expect(listener).toHaveBeenCalledTimes(3);
    expect(listener.mock.calls[0][0]).toMatchObject({ message: 'Buffered 1' });
    expect(listener.mock.calls[1][0]).toMatchObject({
      message: 'Re-entrant message',
    });
    expect(listener.mock.calls[2][0]).toMatchObject({ message: 'Buffered 2' });
  });

  describe('ConsoleLog Event', () => {
    it('should emit console log immediately when a listener is present', () => {
      const listener = vi.fn();
      events.on(CoreEvent.ConsoleLog, listener);

      const payload = {
        type: 'info' as const,
        content: 'Test log',
      };

      events.emitConsoleLog(payload.type, payload.content);

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
    });

    it('should buffer console logs when no listener is present', () => {
      const listener = vi.fn();
      const payload = {
        type: 'warn' as const,
        content: 'Buffered log',
      };

      // Emit while no listeners attached
      events.emitConsoleLog(payload.type, payload.content);
      expect(listener).not.toHaveBeenCalled();

      // Attach listener and drain
      events.on(CoreEvent.ConsoleLog, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
    });

    it('should respect the backlog size limit for console logs', () => {
      const listener = vi.fn();
      const MAX_BACKLOG_SIZE = 10000;

      for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) {
        events.emitConsoleLog('debug', `Log ${i}`);
      }

      events.on(CoreEvent.ConsoleLog, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
      // Verify strictly that the FIRST call was Log 10 (0-9 dropped)
      expect(listener.mock.calls[0][0]).toMatchObject({ content: 'Log 10' });
    });
  });

  describe('Output Event', () => {
    it('should emit output immediately when a listener is present', () => {
      const listener = vi.fn();
      events.on(CoreEvent.Output, listener);

      const payload = {
        isStderr: false,
        chunk: 'Test output',
        encoding: 'utf8' as BufferEncoding,
      };

      events.emitOutput(payload.isStderr, payload.chunk, payload.encoding);

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
    });

    it('should buffer output when no listener is present', () => {
      const listener = vi.fn();
      const payload = {
        isStderr: true,
        chunk: 'Buffered output',
      };

      // Emit while no listeners attached
      events.emitOutput(payload.isStderr, payload.chunk);
      expect(listener).not.toHaveBeenCalled();

      // Attach listener and drain
      events.on(CoreEvent.Output, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(expect.objectContaining(payload));
    });

    it('should respect the backlog size limit for output', () => {
      const listener = vi.fn();
      const MAX_BACKLOG_SIZE = 10000;

      for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) {
        events.emitOutput(false, `Output ${i}`);
      }

      events.on(CoreEvent.Output, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
      // Verify strictly that the FIRST call was Output 10 (0-9 dropped)
      expect(listener.mock.calls[0][0]).toMatchObject({ chunk: 'Output 10' });
    });
  });

  describe('drainBacklogs Transformation', () => {
    it('should transform events during drain', () => {
      const listener = vi.fn();
      events.emitOutput(false, 'stdout chunk');
      events.emitFeedback('info', 'info message');

      events.on(CoreEvent.Output, listener);
      events.on(CoreEvent.UserFeedback, listener);

      events.drainBacklogs(
        <K extends keyof CoreEvents>(event: K, args: CoreEvents[K]) => {
          if (event === (CoreEvent.Output as string)) {
            const payload = args[0] as { isStderr: boolean; chunk: string };
            return {
              event,
              args: [
                { ...payload, isStderr: true },
              ] as unknown as CoreEvents[K],
            };
          }
          return { event, args };
        },
      );

      expect(listener).toHaveBeenCalledTimes(2);
      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({ isStderr: true, chunk: 'stdout chunk' }),
      );
      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({ message: 'info message' }),
      );
    });

    it('should drop events when transform returns undefined', () => {
      const listener = vi.fn();
      events.emitOutput(false, 'drop me');
      events.emitFeedback('info', 'keep me');

      events.on(CoreEvent.Output, listener);
      events.on(CoreEvent.UserFeedback, listener);

      events.drainBacklogs((event, args) => {
        if (event === CoreEvent.Output) {
          return undefined;
        }
        return { event, args };
      });

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(
        expect.objectContaining({ message: 'keep me' }),
      );
    });
  });

  describe('ModelChanged Event', () => {
    it('should emit ModelChanged event with correct payload', () => {
      const listener = vi.fn();
      events.on(CoreEvent.ModelChanged, listener);

      const newModel = 'gemini-2.5-pro';
      events.emitModelChanged(newModel);

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith({ model: newModel });
    });
  });

  describe('Hook Events', () => {
    it('should emit HookStart event with correct payload using helper', () => {
      const listener = vi.fn();
      events.on(CoreEvent.HookStart, listener);

      const payload = {
        hookName: 'test-hook',
        eventName: 'before-agent',
        hookIndex: 1,
        totalHooks: 1,
      };
      events.emitHookStart(payload);

      expect(listener).toHaveBeenCalledWith(payload);
    });

    it('should emit HookEnd event with correct payload using helper', () => {
      const listener = vi.fn();
      events.on(CoreEvent.HookEnd, listener);

      const payload = {
        hookName: 'test-hook',
        eventName: 'before-agent',
        success: true,
      };
      events.emitHookEnd(payload);

      expect(listener).toHaveBeenCalledWith(payload);
    });
  });

  describe('ConsentRequest Event', () => {
    it('should emit consent request immediately when a listener is present', () => {
      const listener = vi.fn();
      events.on(CoreEvent.ConsentRequest, listener);

      const payload = {
        prompt: 'Do you consent?',
        onConfirm: vi.fn(),
      };

      events.emitConsentRequest(payload);

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(payload);
    });

    it('should buffer consent requests when no listener is present', () => {
      const listener = vi.fn();
      const payload = {
        prompt: 'Buffered consent?',
        onConfirm: vi.fn(),
      };

      // Emit while no listeners attached
      events.emitConsentRequest(payload);
      expect(listener).not.toHaveBeenCalled();

      // Attach listener and drain
      events.on(CoreEvent.ConsentRequest, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener).toHaveBeenCalledWith(payload);
    });

    it('should respect the backlog size limit for consent requests', () => {
      const listener = vi.fn();
      const MAX_BACKLOG_SIZE = 10000;

      for (let i = 0; i < MAX_BACKLOG_SIZE + 10; i++) {
        events.emitConsentRequest({
          prompt: `Consent ${i}`,
          onConfirm: vi.fn(),
        });
      }

      events.on(CoreEvent.ConsentRequest, listener);
      events.drainBacklogs();

      expect(listener).toHaveBeenCalledTimes(MAX_BACKLOG_SIZE);
      // Verify strictly that the FIRST call was Consent 10 (0-9 dropped)
      expect(listener.mock.calls[0][0]).toMatchObject({ prompt: 'Consent 10' });
    });
  });

  describe('emitMcpProgress validation', () => {
    const basePayload: McpProgressPayload = {
      serverName: 'test-server',
      callId: 'call-1',
      progressToken: 'token-1',
      progress: 0,
    };

    let listener: ReturnType<typeof vi.fn>;

    afterEach(() => {
      if (listener) {
        coreEvents.off(CoreEvent.McpProgress, listener);
      }
    });

    it('rejects NaN progress', () => {
      listener = vi.fn();
      coreEvents.on(CoreEvent.McpProgress, listener);

      coreEvents.emitMcpProgress({ ...basePayload, progress: NaN });

      expect(listener).not.toHaveBeenCalled();
    });

    it('rejects negative progress', () => {
      listener = vi.fn();
      coreEvents.on(CoreEvent.McpProgress, listener);

      coreEvents.emitMcpProgress({ ...basePayload, progress: -1 });

      expect(listener).not.toHaveBeenCalled();
    });

    it('rejects Infinity progress', () => {
      listener = vi.fn();
      coreEvents.on(CoreEvent.McpProgress, listener);

      coreEvents.emitMcpProgress({ ...basePayload, progress: Infinity });

      expect(listener).not.toHaveBeenCalled();
    });

    it('emits valid progress payload', () => {
      listener = vi.fn();
      coreEvents.on(CoreEvent.McpProgress, listener);

      const payload: McpProgressPayload = {
        ...basePayload,
        progress: 5,
        total: 10,
        message: 'test',
      };
      coreEvents.emitMcpProgress(payload);

      expect(listener).toHaveBeenCalledExactlyOnceWith(payload);
    });
  });
});
