import { describe, it, expect, vi } from 'vitest';
import { ChatService, type ChatToolDispatcher } from '../src/services/chat.service.js';
import type { AgentService } from '../src/services/agent.service.js';
import type { LlmService } from '../src/services/llm.service.js';
import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js';
import type { IChatRepository } from '../src/repositories/chat.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { IVirtualLlmService } from '../src/services/virtual-llm.service.js';
import type { ChatMessage, ChatThread, Prompt } from '@prisma/client';

const NOW = new Date();

/**
 * Tests targeting v3 Stage 1's chat.service kind=virtual branch.
 * Mirror the existing chat-service.test.ts patterns but isolate the
 * adapter-vs-relay dispatch decision.
 */

function mockChatRepo(): IChatRepository {
  const msgs: ChatMessage[] = [];
  const threads: ChatThread[] = [];
  let idCounter = 1;
  return {
    createThread: vi.fn(async ({ agentId, ownerId, title }) => {
      const t: ChatThread = {
        id: `thread-${String(idCounter++)}`, agentId, ownerId,
        title: title ?? '', lastTurnAt: NOW, createdAt: NOW, updatedAt: NOW,
      };
      threads.push(t);
      return t;
    }),
    findThread: vi.fn(async (id: string) => threads.find((t) => t.id === id) ?? null),
    listThreadsByAgent: vi.fn(async () => []),
    listMessages: vi.fn(async () => []),
    appendMessage: vi.fn(async (input) => {
      const m: ChatMessage = {
        id: `msg-${String(idCounter++)}`,
        threadId: input.threadId,
        turnIndex: input.turnIndex ?? msgs.filter((x) => x.threadId === input.threadId).length,
        role: input.role,
        content: input.content,
        toolCalls: (input.toolCalls ?? null) as ChatMessage['toolCalls'],
        toolCallId: input.toolCallId ?? null,
        status: input.status ?? 'complete',
        createdAt: NOW,
      };
      msgs.push(m);
      return m;
    }),
    updateStatus: vi.fn(async (_id, _s) => ({ } as ChatMessage)),
    markPendingAsError: vi.fn(async () => 0),
    touchThread: vi.fn(async () => undefined),
    nextTurnIndex: vi.fn(async () => msgs.length),
  };
}

function mockAgents(): AgentService {
  return {
    getByName: vi.fn(async (name: string) => ({
      id: `agent-${name}`, name, description: '',
      systemPrompt: 'You are a helpful agent.',
      llm: { id: 'llm-1', name: 'vllm-local' },
      project: null,
      defaultPersonality: null,
      proxyModelName: null,
      defaultParams: {},
      extras: {},
      ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
    })),
  } as unknown as AgentService;
}

function mockLlmsVirtual(): LlmService {
  const baseRow = (name: string): Record<string, unknown> => ({
    id: 'llm-1', name, type: 'openai', model: 'fake',
    url: '', tier: 'fast', description: '',
    apiKeySecretId: null, apiKeySecretKey: null,
    extraConfig: {},
    poolName: null,
    kind: 'virtual',
    providerSessionId: null,
    status: 'active',
    lastHeartbeatAt: NOW,
    inactiveSince: null,
    version: 1, createdAt: NOW, updatedAt: NOW,
  });
  return {
    getByName: vi.fn(async (name: string) => ({
      ...baseRow(name),
      apiKeyRef: null,
    })),
    findByPoolName: vi.fn(async (poolName: string) => [baseRow(poolName)]),
    resolveApiKey: vi.fn(async () => ''),
  } as unknown as LlmService;
}

function mockPromptRepo(): IPromptRepository {
  return {
    findAll: vi.fn(async () => []),
    findGlobal: vi.fn(async () => []),
    findByAgent: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByNameAndProject: vi.fn(async () => null),
    findByNameAndAgent: vi.fn(async () => null),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
  };
}

function mockTools(): ChatToolDispatcher {
  return { listTools: vi.fn(async () => []), callTool: vi.fn(async () => ({ ok: true })) };
}

function emptyAdapterRegistry(): LlmAdapterRegistry {
  return {
    get: () => { throw new Error('adapter should not be used for kind=virtual'); },
  } as unknown as LlmAdapterRegistry;
}

function mockVirtualLlms(opts: {
  reply?: string;
  rejectWith?: Error;
  streamingChunks?: string[];
}): IVirtualLlmService {
  const enqueueInferTask = vi.fn(async (_name: string, _body: unknown, streaming: boolean) => {
    if (opts.rejectWith !== undefined) {
      return {
        taskId: 't-1',
        done: Promise.reject(opts.rejectWith),
        onChunk: () => () => undefined,
      };
    }
    if (!streaming) {
      const body = {
        choices: [{ message: { content: opts.reply ?? 'hi from relay' }, finish_reason: 'stop' }],
      };
      return {
        taskId: 't-1',
        done: Promise.resolve({ status: 200, body }),
        onChunk: () => () => undefined,
      };
    }
    // Streaming path: collect subscribers, push the configured chunks
    // synchronously, then resolve done.
    const subs = new Set<(c: { data: string; done?: boolean }) => void>();
    const chunks = opts.streamingChunks ?? ['{"choices":[{"delta":{"content":"hi from relay"}}]}'];
    return {
      taskId: 't-1',
      done: (async (): Promise<{ status: number; body: unknown }> => {
        // Wait long enough for the caller to register subscribers
        // before fanning chunks. Promise.resolve() isn't enough — the
        // microtask running this IIFE is queued ahead of the caller's
        // await on enqueueInferTask, so subs would still be empty.
        await new Promise((r) => setTimeout(r, 0));
        for (const c of chunks) for (const s of subs) s({ data: c });
        for (const s of subs) s({ data: '[DONE]', done: true });
        return { status: 200, body: null };
      })(),
      onChunk: (cb): (() => void) => { subs.add(cb); return () => subs.delete(cb); },
    };
  });
  return {
    register: vi.fn(),
    heartbeat: vi.fn(),
    bindSession: vi.fn(),
    unbindSession: vi.fn(),
    enqueueInferTask: enqueueInferTask as unknown as IVirtualLlmService['enqueueInferTask'],
    completeTask: vi.fn(),
    pushTaskChunk: vi.fn(),
    failTask: vi.fn(),
    gcSweep: vi.fn(),
  };
}

describe('ChatService — kind=virtual branch (v3 Stage 1)', () => {
  it('non-streaming relays through VirtualLlmService.enqueueInferTask', async () => {
    const chatRepo = mockChatRepo();
    const virtual = mockVirtualLlms({ reply: 'hello back from local' });
    const svc = new ChatService(
      mockAgents(),
      mockLlmsVirtual(),
      emptyAdapterRegistry(),
      chatRepo,
      mockPromptRepo(),
      mockTools(),
      undefined,
      virtual,
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('hello back from local');
    expect(virtual.enqueueInferTask).toHaveBeenCalledWith(
      'vllm-local',
      expect.objectContaining({ messages: expect.any(Array) }),
      false,
      // v5: chat.service passes failFast:true so its pool failover loop
      // surfaces transport errors quickly instead of waiting on the
      // durable queue's 10-min timeout.
      { failFast: true },
    );
  });

  it('streaming relays through VirtualLlmService and emits the same text deltas', async () => {
    const chatRepo = mockChatRepo();
    const virtual = mockVirtualLlms({
      streamingChunks: [
        '{"choices":[{"delta":{"content":"hello "}}]}',
        '{"choices":[{"delta":{"content":"world"}}]}',
      ],
    });
    const svc = new ChatService(
      mockAgents(),
      mockLlmsVirtual(),
      emptyAdapterRegistry(),
      chatRepo,
      mockPromptRepo(),
      mockTools(),
      undefined,
      virtual,
    );
    const deltas: string[] = [];
    for await (const evt of svc.chatStream({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' })) {
      if (evt.type === 'text') deltas.push(evt.delta);
      if (evt.type === 'final') break;
    }
    expect(deltas.join('')).toBe('hello world');
    expect(virtual.enqueueInferTask).toHaveBeenCalledWith(
      'vllm-local',
      expect.objectContaining({ messages: expect.any(Array), stream: true }),
      true,
      { failFast: true },
    );
  });

  it('non-streaming throws a clear error when virtualLlms isn\'t wired but the row is virtual', async () => {
    const svc = new ChatService(
      mockAgents(),
      mockLlmsVirtual(),
      emptyAdapterRegistry(),
      mockChatRepo(),
      mockPromptRepo(),
      mockTools(),
      // no personalities, no virtualLlms
    );
    await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
      .rejects.toThrow(/virtualLlms dispatcher not wired/);
  });

  it('non-streaming surfaces the relay\'s rejection (e.g. publisher offline) up to the caller', async () => {
    const virtual = mockVirtualLlms({ rejectWith: Object.assign(new Error('publisher offline'), { statusCode: 503 }) });
    const svc = new ChatService(
      mockAgents(),
      mockLlmsVirtual(),
      emptyAdapterRegistry(),
      mockChatRepo(),
      mockPromptRepo(),
      mockTools(),
      undefined,
      virtual,
    );
    await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
      .rejects.toThrow(/publisher offline/);
  });
});
