import { afterEach, describe, expect, it, vi } from 'vitest';
import type { LLMEvent } from '../llm/openai-compat.js';
import { classifyPiece, type PieceDescription } from './piece-classifier.js';

// ---------------------------------------------------------------------------
// classifyPiece (ENG-052) — the async LLM-orchestration entry point.
//
// House style §4: the LLM client is a hand-rolled FakeClient implementing the
// async-generator chat() method. classifyPiece consumes the stream looking for
// text / error / done events, then runs parseClassificationResponse over the
// accumulated text. It returns the chosen piece name, or null (the caller —
// worker-bootstrap.ts — applies the actual default-piece fallback when null).
//
// classifyPiece signature:
//   classifyPiece(client, taskText, pieces, fileNames, timeoutMs?, userId?)
// ---------------------------------------------------------------------------

// A FakeClient whose chat() yields a scripted batch of LLMEvents once.
class FakeClient {
  public lastMessages: unknown = null;
  public lastOpts: unknown = null;
  constructor(private readonly events: LLMEvent[]) {}
  async *chat(
    messages: unknown,
    _tools?: unknown,
    _signal?: unknown,
    opts?: unknown,
  ): AsyncGenerator<LLMEvent> {
    this.lastMessages = messages;
    this.lastOpts = opts;
    for (const ev of this.events) yield ev;
  }
}

// A FakeClient whose chat() throws synchronously when iterated — exercises the
// try/catch around the for-await loop (LLM call failed → return null path).
class ThrowingClient {
  // eslint-disable-next-line require-yield
  async *chat(): AsyncGenerator<LLMEvent> {
    throw new Error('connection refused');
  }
}

const PIECES: PieceDescription[] = [
  { name: 'chat', description: '汎用デフォルト' },
  { name: 'research', description: '構造化された調査レポート作成' },
  { name: 'data-process', description: 'データ加工・集計・分析' },
];

describe('classifyPiece async path', () => {
  afterEach(() => vi.restoreAllMocks());

  it('returns the LLM-chosen valid piece name', async () => {
    const client = new FakeClient([
      { type: 'text', text: 'research' },
      { type: 'done' },
    ]);
    const result = await classifyPiece(
      client as never,
      'Rust の最新動向を構造化レポートにまとめて',
      PIECES,
      [],
    );
    expect(result).toBe('research');
  });

  it('accumulates streamed text fragments before parsing', async () => {
    // The model streams the answer in two text chunks; classifyPiece must
    // concatenate them before parsing (otherwise neither fragment matches).
    const client = new FakeClient([
      { type: 'text', text: 'data-' },
      { type: 'text', text: 'process' },
      { type: 'done' },
    ]);
    const result = await classifyPiece(client as never, '集計して', PIECES, []);
    expect(result).toBe('data-process');
  });

  it('returns null when the LLM names an unknown / invalid piece (caller falls back to default)', async () => {
    const client = new FakeClient([
      { type: 'text', text: 'totally-not-a-piece' },
      { type: 'done' },
    ]);
    const result = await classifyPiece(client as never, '何かの依頼', PIECES, []);
    // null is the documented "could not classify" signal. The default-piece
    // fallback lives in the caller (worker-bootstrap.ts), not here.
    expect(result).toBeNull();
  });

  it('returns null on an empty LLM response without throwing', async () => {
    const client = new FakeClient([{ type: 'done' }]);
    const result = await classifyPiece(client as never, 'こんにちは', PIECES, []);
    expect(result).toBeNull();
  });

  it('returns null when the stream emits an error event (no throw)', async () => {
    const client = new FakeClient([
      { type: 'error', error: 'upstream 500' },
      { type: 'done' },
    ]);
    const result = await classifyPiece(client as never, 'タスク', PIECES, []);
    expect(result).toBeNull();
  });

  it('catches a thrown client error and returns null (does not propagate)', async () => {
    const client = new ThrowingClient();
    // Must resolve to null, not reject — the caller treats null as "use default".
    await expect(
      classifyPiece(client as never, 'タスク', PIECES, []),
    ).resolves.toBeNull();
  });

  it('threads userId into the chat() opts so per-user routing applies', async () => {
    const client = new FakeClient([
      { type: 'text', text: 'chat' },
      { type: 'done' },
    ]);
    await classifyPiece(client as never, 'q', PIECES, [], 8000, 'user-42');
    expect(client.lastOpts).toMatchObject({ userId: 'user-42' });
  });

  it('falls back to null when the LLM call times out before emitting', async () => {
    vi.useFakeTimers();
    try {
      // chat() never yields — the Promise.race timeout branch must win.
      class HangingClient {
        // eslint-disable-next-line require-yield
        async *chat(): AsyncGenerator<LLMEvent> {
          await new Promise(() => {}); // never resolves
        }
      }
      const promise = classifyPiece(
        new HangingClient() as never,
        'q',
        PIECES,
        [],
        50, // 50ms timeout
      );
      await vi.advanceTimersByTimeAsync(60);
      await expect(promise).resolves.toBeNull();
    } finally {
      vi.useRealTimers();
    }
  });
});
