import { describe, it, expect, afterEach, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { ChatbotApiError, ChatbotApiService } from './chatbot-api.service';
import { ChatbotService } from './chatbot.service';
import type { SseFrame } from './sse-parser';

/**
 * Drives the chatbot state machine through a fake API service that
 * returns canned SSE frame sequences. Confirms the open/close
 * transitions, the streaming buffer, error rendering, citation
 * accumulation, and the AbortController-based stop path.
 */

/**
 * Default `FakeApi.next` value — an async iterable that yields no
 * frames, mirroring an already-finished server stream. Implemented
 * as a regular function returning an AsyncIterable rather than a
 * generator so eslint's `require-yield` rule is satisfied.
 */
function emptyStream(): AsyncIterable<SseFrame> {
  return {
    [Symbol.asyncIterator](): AsyncIterator<SseFrame> {
      return {
        next: () => Promise.resolve({ value: undefined as never, done: true }),
      };
    },
  };
}

class FakeApi {
  /** Set by each test to control what `openChatStream` yields. */
  next: (signal: AbortSignal) => Promise<AsyncIterable<SseFrame>> = () =>
    Promise.resolve(emptyStream());

  /** Captures the last request body so the test can assert request shape. */
  lastBody: unknown = null;

  /** Captures the AbortSignal so the test can observe cancellation. */
  lastSignal: AbortSignal | null = null;

  openChatStream(body: unknown, signal: AbortSignal): Promise<AsyncIterable<SseFrame>> {
    this.lastBody = body;
    this.lastSignal = signal;
    return this.next(signal);
  }
}

function makeService(): { service: ChatbotService; api: FakeApi } {
  const api = new FakeApi();
  TestBed.configureTestingModule({
    providers: [{ provide: ChatbotApiService, useValue: api }],
  });
  return { service: TestBed.inject(ChatbotService), api };
}

async function* frames(...items: SseFrame[]): AsyncIterable<SseFrame> {
  for (const f of items) {
    yield f;
  }
}

describe('ChatbotService — view state', () => {
  afterEach(() => TestBed.resetTestingModule());

  it('starts closed', () => {
    const { service } = makeService();
    expect(service.view()).toBe('closed');
    expect(service.isOpen()).toBe(false);
  });

  it('open() / close() toggles the view', () => {
    const { service } = makeService();
    service.open();
    expect(service.view()).toBe('windowed');
    service.close();
    expect(service.view()).toBe('closed');
  });

  it('toggleFullscreen() flips between windowed and fullscreen', () => {
    const { service } = makeService();
    service.open();
    service.toggleFullscreen();
    expect(service.view()).toBe('fullscreen');
    service.toggleFullscreen();
    expect(service.view()).toBe('windowed');
  });

  it('close() clears the selected citation', () => {
    const { service } = makeService();
    service.selectCitation({
      index: 1,
      chunkId: 'c',
      documentId: 'd',
      source: 's',
      score: 0.5,
      snippet: '...',
    });
    expect(service.selectedCitation()).not.toBeNull();
    service.close();
    expect(service.selectedCitation()).toBeNull();
  });
});

describe('ChatbotService — send() streams', () => {
  afterEach(() => TestBed.resetTestingModule());

  it('appends user + assistant messages and streams token frames into the assistant content', async () => {
    const { service, api } = makeService();
    api.next = () =>
      Promise.resolve(
        frames(
          { event: 'token', data: { value: 'Hello' } },
          { event: 'token', data: { value: ', world' } },
          { event: 'done', data: {} },
        ),
      );

    await service.send('hi');

    const messages = service.messages();
    expect(messages).toHaveLength(2);
    expect(messages[0]).toMatchObject({ role: 'user', content: 'hi', done: true });
    expect(messages[1]).toMatchObject({
      role: 'assistant',
      content: 'Hello, world',
      done: true,
    });
    expect(messages[1]?.errorCode).toBeUndefined();
    expect(service.isStreaming()).toBe(false);
  });

  it('opens the panel when send() is called from closed state', async () => {
    const { service, api } = makeService();
    api.next = () => Promise.resolve(frames({ event: 'done', data: {} }));

    expect(service.isOpen()).toBe(false);
    await service.send('hello');
    expect(service.isOpen()).toBe(true);
  });

  it('builds the request body from the visible history without the empty placeholder', async () => {
    const { service, api } = makeService();
    api.next = () =>
      Promise.resolve(
        frames({ event: 'token', data: { value: 'A' } }, { event: 'done', data: {} }),
      );

    await service.send('first');
    expect(api.lastBody).toEqual({
      messages: [{ role: 'user', content: 'first' }],
    });

    api.next = () => Promise.resolve(frames({ event: 'done', data: {} }));
    await service.send('second');
    expect(api.lastBody).toEqual({
      messages: [
        { role: 'user', content: 'first' },
        { role: 'assistant', content: 'A' },
        { role: 'user', content: 'second' },
      ],
    });
  });

  it('accumulates citations with 1-based indices', async () => {
    const { service, api } = makeService();
    api.next = () =>
      Promise.resolve(
        frames(
          { event: 'token', data: { value: 'See' } },
          {
            event: 'citation',
            data: {
              chunkId: 'c1',
              documentId: 'd1',
              source: 's1',
              score: 0.9,
              snippet: 'sn1',
            },
          },
          {
            event: 'citation',
            data: {
              chunkId: 'c2',
              documentId: 'd2',
              source: 's2',
              score: 0.8,
              snippet: 'sn2',
            },
          },
          { event: 'done', data: {} },
        ),
      );

    await service.send('q');
    const assistant = service.messages()[1]!;
    expect(assistant.citations).toHaveLength(2);
    expect(assistant.citations[0]).toMatchObject({ index: 1, chunkId: 'c1' });
    expect(assistant.citations[1]).toMatchObject({ index: 2, chunkId: 'c2' });
  });

  it('marks the message as errored on an error frame', async () => {
    const { service, api } = makeService();
    api.next = () =>
      Promise.resolve(
        frames({
          event: 'error',
          data: { code: 'urn:apf-ai:unavailable', message: 'down', retriable: true },
        }),
      );

    await service.send('q');
    const assistant = service.messages()[1]!;
    expect(assistant.errorCode).toBe('urn:apf-ai:unavailable');
    expect(assistant.errorMessage).toBe('down');
    expect(assistant.done).toBe(true);
    expect(service.isStreaming()).toBe(false);
  });

  it('marks the message as errored on a ChatbotApiError', async () => {
    const { service, api } = makeService();
    api.next = () => Promise.reject(new ChatbotApiError('unauthenticated', 'session gone', 401));

    await service.send('q');
    const assistant = service.messages()[1]!;
    expect(assistant.errorCode).toBe('unauthenticated');
    expect(assistant.errorMessage).toBe('session gone');
    expect(assistant.done).toBe(true);
  });

  it('does not start a second stream while one is already streaming', async () => {
    const { service, api } = makeService();
    const callCount = vi.fn();
    api.next = () => {
      callCount();
      return Promise.resolve(frames({ event: 'done', data: {} }));
    };

    const first = service.send('a');
    void service.send('b'); // ignored — service is currently streaming
    await first;
    expect(callCount).toHaveBeenCalledTimes(1);
  });

  it('stop() aborts the in-flight stream and marks the message done', async () => {
    const { service, api } = makeService();
    const observed: { signal?: AbortSignal } = {};
    api.next = (signal) => {
      observed.signal = signal;
      // Never-ending stream so the test can observe abort. The
      // generator yields one no-op frame (so eslint `require-yield`
      // is satisfied) and then awaits the abort signal.
      async function* hang(): AsyncIterable<SseFrame> {
        yield { event: 'comment', data: 'waiting-for-abort' };
        await new Promise<void>((resolve) => signal.addEventListener('abort', () => resolve()));
      }
      return Promise.resolve(hang());
    };

    const pending = service.send('q');
    // Wait a microtask so the service has time to set up.
    await Promise.resolve();
    service.stop();
    await pending;

    expect(observed.signal?.aborted).toBe(true);
    expect(service.isStreaming()).toBe(false);
    expect(service.messages()[1]?.done).toBe(true);
  });

  it('ignores empty / whitespace prompts', async () => {
    const { service, api } = makeService();
    const called = vi.fn();
    api.next = () => {
      called();
      return Promise.resolve(frames({ event: 'done', data: {} }));
    };

    await service.send('   ');
    expect(called).not.toHaveBeenCalled();
    expect(service.messages()).toHaveLength(0);
  });
});
