import { describe, it, expect, vi } from 'vitest';
import { ChatService, MAX_ITERATIONS, TOOL_NAME_SEPARATOR, 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 { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js';
import type { IChatRepository } from '../src/repositories/chat.repository.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { IPersonalityRepository } from '../src/repositories/personality.repository.js';
import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client';

const NOW = new Date();

function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: ChatThread[] } {
  const msgs: ChatMessage[] = [];
  const threads: ChatThread[] = [];
  let idCounter = 1;

  return {
    _msgs: msgs,
    _threads: threads,
    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 (agentId: string) => threads.filter((t) => t.agentId === agentId)),
    listMessages: vi.fn(async (threadId: string) =>
      msgs.filter((m) => m.threadId === threadId).sort((a, b) => a.turnIndex - b.turnIndex)),
    appendMessage: vi.fn(async (input) => {
      const turnIndex = input.turnIndex ?? msgs.filter((m) => m.threadId === input.threadId).length;
      const m: ChatMessage = {
        id: `msg-${String(idCounter++)}`,
        threadId: input.threadId,
        turnIndex,
        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: string, status) => {
      const m = msgs.find((x) => x.id === id);
      if (!m) throw new Error('not found');
      m.status = status;
      return m;
    }),
    markPendingAsError: vi.fn(async (threadId: string) => {
      let n = 0;
      for (const m of msgs) {
        if (m.threadId === threadId && m.status === 'pending') {
          m.status = 'error';
          n += 1;
        }
      }
      return n;
    }),
    touchThread: vi.fn(async () => undefined),
    nextTurnIndex: vi.fn(async (threadId: string) =>
      msgs.filter((m) => m.threadId === threadId).length),
  };
}

function mockPromptRepo(rows: Prompt[] = []): IPromptRepository {
  return {
    findAll: vi.fn(async () => rows),
    findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)),
    findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)),
    findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null),
    findByNameAndProject: vi.fn(async () => null),
    findByNameAndAgent: vi.fn(async () => null),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
  } as unknown as IPromptRepository;
}

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

function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService {
  return {
    getByName: vi.fn(async (name: string) => ({
      id: `agent-${name}`,
      name,
      description: 'desc',
      systemPrompt: 'You are a helpful agent.',
      llm: { id: 'llm-1', name: 'qwen3-thinking' },
      project: name === 'no-project'
        ? null
        : { id: 'proj-1', name: 'mcpctl-dev' },
      defaultPersonality: opts.defaultPersonality ?? null,
      proxyModelName: null,
      defaultParams: { temperature: 0.5 },
      extras: {},
      ownerId: 'owner-1',
      version: 1,
      createdAt: NOW,
      updatedAt: NOW,
    })),
  } as unknown as AgentService;
}

function mockLlms(opts: { kind?: 'public' | 'virtual' } = {}): LlmService {
  // v4: prepareContext now resolves a pool by effective key. For unit
  // tests that pass a single agent.llm we return that one row twice —
  // once for getByName (LlmView shape) and once for findByPoolName (raw
  // Llm shape with the same name) so the dispatcher's poolCandidates
  // ends up with exactly one member, matching pre-v4 behavior.
  const baseRow = (name: string): Record<string, unknown> => ({
    id: 'llm-1', name, type: 'openai', model: 'qwen3-thinking',
    url: '', tier: 'fast', description: '',
    apiKeySecretId: null, apiKeySecretKey: null,
    extraConfig: {},
    poolName: null,
    kind: opts.kind ?? 'public',
    providerSessionId: null,
    status: 'active',
    lastHeartbeatAt: null,
    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 () => 'fake-key'),
  } as unknown as LlmService;
}

/** Adapter that yields a scripted sequence of canned responses, one per call. */
function scriptedAdapter(responses: NonStreamingResult[]): LlmAdapter {
  let i = 0;
  return {
    kind: 'scripted',
    infer: vi.fn(async (_ctx: InferContext) => {
      const r = responses[i] ?? responses[responses.length - 1];
      i += 1;
      if (r === undefined) throw new Error('no scripted response');
      return r;
    }),
    stream: async function*(_ctx: InferContext) {
      yield { data: '[DONE]', done: true };
    },
  };
}

function adapterRegistry(adapter: LlmAdapter): LlmAdapterRegistry {
  return { get: () => adapter } as unknown as LlmAdapterRegistry;
}

function chatCompletion(content: string): NonStreamingResult {
  return {
    status: 200,
    body: {
      id: 'cmpl-1',
      object: 'chat.completion',
      choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
    },
  };
}

function toolCall(name: string, args: Record<string, unknown>): NonStreamingResult {
  return {
    status: 200,
    body: {
      id: 'cmpl-1',
      object: 'chat.completion',
      choices: [{
        index: 0,
        message: {
          role: 'assistant',
          content: '',
          tool_calls: [{
            id: `call-${name}`,
            type: 'function',
            function: { name, arguments: JSON.stringify(args) },
          }],
        },
        finish_reason: 'tool_calls',
      }],
    },
  };
}

describe('ChatService', () => {
  it('plain text turn — persists user + assistant rows and returns the reply', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('hello back')]);
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );

    const result = await svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
    });

    expect(result.assistant).toBe('hello back');
    const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId);
    expect(stored.map((m) => m.role)).toEqual(['user', 'assistant']);
    expect(stored[1]?.status).toBe('complete');
  });

  it('runs a full tool-use round-trip and ends with a text reply', async () => {
    const chatRepo = mockChatRepo();
    const tools = mockTools({
      listTools: vi.fn(async () => [{
        name: `grafana${TOOL_NAME_SEPARATOR}query`,
        description: 'query grafana',
        parameters: { type: 'object', properties: {} },
      }]),
      callTool: vi.fn(async () => ({ rows: [{ value: 42 }] })),
    });
    const adapter = scriptedAdapter([
      toolCall(`grafana${TOOL_NAME_SEPARATOR}query`, { q: 'cpu' }),
      chatCompletion('the answer is 42'),
    ]);
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );

    const result = await svc.chat({
      agentName: 'reviewer',
      userMessage: 'what is cpu?',
      ownerId: 'owner-1',
    });

    expect(result.assistant).toBe('the answer is 42');
    expect(tools.callTool).toHaveBeenCalledWith({
      projectId: 'proj-1',
      serverName: 'grafana',
      toolName: 'query',
      args: { q: 'cpu' },
    });
    const stored = chatRepo._msgs.filter((m) => m.threadId === result.threadId);
    expect(stored.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']);
    // No `pending` rows leaked.
    expect(stored.every((m) => m.status === 'complete')).toBe(true);
    // Tool turn's toolCallId links back.
    const toolTurn = stored.find((m) => m.role === 'tool');
    expect(toolTurn?.toolCallId).toBe(`call-grafana${TOOL_NAME_SEPARATOR}query`);
  });

  it('caps the loop at MAX_ITERATIONS when the model never settles', async () => {
    const chatRepo = mockChatRepo();
    const tools = mockTools({
      listTools: vi.fn(async () => [{
        name: `g${TOOL_NAME_SEPARATOR}t`,
        description: '',
        parameters: { type: 'object' },
      }]),
      callTool: vi.fn(async () => ({})),
    });
    // Always return a tool_call → the loop never reaches a terminal turn.
    const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );

    await expect(svc.chat({
      agentName: 'reviewer',
      userMessage: 'loop forever',
      ownerId: 'owner-1',
    })).rejects.toThrow(new RegExp(`exceeded ${String(MAX_ITERATIONS)}`));

    // After failure, no row should remain `pending`.
    expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true);
  });

  it('flips pending rows to error when the adapter throws mid-loop', async () => {
    const chatRepo = mockChatRepo();
    const tools = mockTools({
      listTools: vi.fn(async () => [{
        name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {},
      }]),
      callTool: vi.fn(async () => ({})),
    });
    const adapter: LlmAdapter = {
      kind: 'fail-after-one',
      infer: vi.fn()
        .mockResolvedValueOnce(toolCall(`g${TOOL_NAME_SEPARATOR}t`, {}))
        .mockRejectedValueOnce(new Error('upstream blew up')),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );

    await expect(svc.chat({
      agentName: 'reviewer',
      userMessage: 'go',
      ownerId: 'owner-1',
    })).rejects.toThrow('upstream blew up');

    expect(chatRepo._msgs.some((m) => m.status === 'error')).toBe(false);
    expect(chatRepo._msgs.every((m) => m.status !== 'pending')).toBe(true);
  });

  it('merges per-call params over agent.defaultParams (override wins)', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    await svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
      params: { temperature: 0.9, max_tokens: 256 },
    });
    const ctx = inferSpy.mock.calls[0][0] as InferContext;
    expect(ctx.body.temperature).toBe(0.9);
    expect(ctx.body.max_tokens).toBe(256);
  });

  it('forwards `extra` keys into the body for provider-specific knobs', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    await svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
      params: { extra: { metadata: { user_id: 'abc' }, repetition_penalty: 1.05 } },
    });
    const ctx = inferSpy.mock.calls[0][0] as InferContext;
    expect((ctx.body as Record<string, unknown>)['repetition_penalty']).toBe(1.05);
    expect((ctx.body as Record<string, unknown>)['metadata']).toEqual({ user_id: 'abc' });
  });

  it('builds a system block from agent.systemPrompt + project prompts (priority desc)', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const prompts: Prompt[] = [
      {
        id: 'p1', name: 'low', content: 'LOW prompt',
        projectId: 'proj-1', priority: 1, summary: null, chapters: null,
        linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW,
      },
      {
        id: 'p2', name: 'high', content: 'HIGH prompt',
        projectId: 'proj-1', priority: 9, summary: null, chapters: null,
        linkTarget: null, version: 1, createdAt: NOW, updatedAt: NOW,
      },
    ];
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(prompts), mockTools(),
    );
    await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    const ctx = inferSpy.mock.calls[0][0] as InferContext;
    const sys = ctx.body.messages.find((m) => m.role === 'system');
    expect(typeof sys?.content).toBe('string');
    const text = sys?.content as string;
    // High-priority prompt comes before low-priority.
    expect(text.indexOf('HIGH prompt')).toBeLessThan(text.indexOf('LOW prompt'));
    // Agent's own system prompt leads.
    expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('HIGH prompt'));
  });

  it('refuses tool calls when the agent has no project attached', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([toolCall(`x${TOOL_NAME_SEPARATOR}y`, {})]);
    const tools = mockTools({
      listTools: vi.fn(async () => [{ name: `x${TOOL_NAME_SEPARATOR}y`, description: '', parameters: {} }]),
    });
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );
    await expect(svc.chat({
      agentName: 'no-project',
      userMessage: 'go',
      ownerId: 'owner-1',
    })).rejects.toThrow(/Project/);
  });

  it('honours tools_allowlist (filters tools before sending to adapter)', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const tools = mockTools({
      listTools: vi.fn(async () => [
        { name: `s1${TOOL_NAME_SEPARATOR}a`, description: '', parameters: {} },
        { name: `s1${TOOL_NAME_SEPARATOR}b`, description: '', parameters: {} },
      ]),
    });
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );
    await svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
      params: { tools_allowlist: [`s1${TOOL_NAME_SEPARATOR}a`] },
    });
    const ctx = inferSpy.mock.calls[0][0] as InferContext;
    expect(ctx.body.tools).toHaveLength(1);
    expect(ctx.body.tools?.[0]?.function.name).toBe(`s1${TOOL_NAME_SEPARATOR}a`);
  });

  // Regression: reasoning_content (qwen3-thinking, deepseek-reasoner, o1)
  // streams as `thinking` chunks, separate from `text`.
  // Without this, the model's 30-90s reasoning phase looks like dead air to
  // the REPL — caught by user feedback during the agents-feature shakedown.
  it('chatStream surfaces reasoning_content deltas as `thinking` chunks', async () => {
    const chatRepo = mockChatRepo();
    // Adapter that yields a sequence of openai-format chunks: 2 reasoning
    // deltas, then 1 content delta, then [DONE].
    const adapter: LlmAdapter = {
      kind: 'scripted-thinking',
      infer: vi.fn(),
      stream: async function*() {
        yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'Let me think... ' }, finish_reason: null }] }) };
        yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'OK, ready.' }, finish_reason: null }] }) };
        yield { data: JSON.stringify({ choices: [{ delta: { content: 'DONE' }, finish_reason: 'stop' }] }) };
        yield { data: '[DONE]', done: true };
      },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );

    const chunks: Array<{ type: string; delta?: string }> = [];
    for await (const chunk of svc.chatStream({
      agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1',
    })) {
      chunks.push({ type: chunk.type, delta: chunk.delta });
    }

    // Expect: 2 thinking + 1 text + 1 final
    expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta))
      .toEqual(['Let me think... ', 'OK, ready.']);
    expect(chunks.filter((c) => c.type === 'text').map((c) => c.delta)).toEqual(['DONE']);
    expect(chunks.find((c) => c.type === 'final')).toBeDefined();

    // Reasoning is NOT persisted to the thread — only assistant content.
    const assistantTurn = chatRepo._msgs.find((m) => m.role === 'assistant');
    expect(assistantTurn?.content).toBe('DONE');
    expect(assistantTurn?.content).not.toContain('Let me think');
  });

  // Regression: thinking models with a tight max_tokens budget produce
  // `reasoning_content` only and leave `content` null. Without falling back
  // to reasoning, the assistant turn was empty and the smoke test saw an
  // empty stdout. This covers BOTH chat() (non-streaming) and chatStream()
  // (synthetic final text frame so the CLI's stdout matches what's
  // persisted to the thread).
  it('chat falls back to reasoning_content when content is null', async () => {
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'thinking-truncated',
      infer: vi.fn(async () => ({
        status: 200,
        body: {
          id: 'cmpl-1',
          object: 'chat.completion',
          choices: [{
            index: 0,
            message: { role: 'assistant', content: null, reasoning_content: 'Thinking out loud about the answer' },
            finish_reason: 'stop',
          }],
        },
      })),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('Thinking out loud about the answer');
    const stored = chatRepo._msgs.find((m) => m.role === 'assistant');
    expect(stored?.content).toBe('Thinking out loud about the answer');
  });

  it('chat appends [response truncated by max_tokens] when finish_reason is "length"', async () => {
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'thinking-clipped',
      infer: vi.fn(async () => ({
        status: 200,
        body: {
          choices: [{
            index: 0,
            message: { role: 'assistant', content: null, reasoning_content: 'partial reasoning that ran out of' },
            finish_reason: 'length',
          }],
        },
      })),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toContain('partial reasoning that ran out of');
    expect(result.assistant).toContain('[response truncated by max_tokens]');
  });

  it('chat prefers content when both content and reasoning_content are present', async () => {
    // Thinking models that DO produce content shouldn't see the reasoning
    // bleed into the response — that's what the streaming path's
    // text/thinking split is for, and the non-streaming path should match.
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'thinking-with-content',
      infer: vi.fn(async () => ({
        status: 200,
        body: {
          choices: [{
            index: 0,
            message: { role: 'assistant', content: 'real answer', reasoning_content: 'background thinking' },
            finish_reason: 'stop',
          }],
        },
      })),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('real answer');
    expect(result.assistant).not.toContain('background thinking');
  });

  it('chatStream emits a synthetic text frame and persists reasoning when content is empty', async () => {
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'thinking-only-stream',
      infer: vi.fn(),
      stream: async function*() {
        yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'thinking ' }, finish_reason: null }] }) };
        yield { data: JSON.stringify({ choices: [{ delta: { reasoning_content: 'more.' }, finish_reason: 'stop' }] }) };
        yield { data: '[DONE]', done: true };
      },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const chunks: Array<{ type: string; delta?: string }> = [];
    for await (const c of svc.chatStream({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' })) {
      chunks.push({ type: c.type, delta: c.delta });
    }
    // 2 thinking deltas (live), 1 synthesized text frame, 1 final.
    expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta)).toEqual(['thinking ', 'more.']);
    expect(chunks.filter((c) => c.type === 'text').map((c) => c.delta)).toEqual(['thinking more.']);
    // The thread message captures the synthesized text so resumed chats see
    // a coherent assistant turn (rather than blank).
    const stored = chatRepo._msgs.find((m) => m.role === 'assistant');
    expect(stored?.content).toBe('thinking more.');
  });

  // Regression: provider_specific_fields.reasoning_content shape (LiteLLM
  // passthrough from vLLM) is also recognized.
  it('chatStream recognizes LiteLLM provider_specific_fields.reasoning_content', async () => {
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'scripted-litellm',
      infer: vi.fn(),
      stream: async function*() {
        yield { data: JSON.stringify({ choices: [{ delta: { provider_specific_fields: { reasoning_content: 'thinking via litellm...' } }, finish_reason: null }] }) };
        yield { data: JSON.stringify({ choices: [{ delta: { content: 'ok' }, finish_reason: 'stop' }] }) };
        yield { data: '[DONE]', done: true };
      },
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );

    const chunks: Array<{ type: string; delta?: string }> = [];
    for await (const chunk of svc.chatStream({
      agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1',
    })) {
      chunks.push({ type: chunk.type, delta: chunk.delta });
    }
    expect(chunks.filter((c) => c.type === 'thinking').map((c) => c.delta))
      .toEqual(['thinking via litellm...']);
  });

  // ── v4: LB pool by shared name ──

  // Helper: build a multi-member mock LlmService where all members share an
  // effective pool key. `nameToFail` lets a test mark specific names as
  // throwing on dispatch, exercising failover.
  function mockLlmsPool(opts: {
    pinnedName: string;
    poolName: string | null;
    members: Array<{ name: string; status?: 'active' | 'inactive'; kind?: 'public' | 'virtual' }>;
  }): LlmService {
    const baseRow = (m: { name: string; status?: 'active' | 'inactive'; kind?: 'public' | 'virtual' }): Record<string, unknown> => ({
      id: `id-${m.name}`,
      name: m.name,
      type: 'openai',
      model: 'qwen3-thinking',
      url: '',
      tier: 'fast',
      description: '',
      apiKeySecretId: null,
      apiKeySecretKey: null,
      extraConfig: {},
      poolName: opts.poolName,
      kind: m.kind ?? 'public',
      providerSessionId: null,
      status: m.status ?? 'active',
      lastHeartbeatAt: null,
      inactiveSince: null,
      version: 1,
      createdAt: NOW,
      updatedAt: NOW,
    });
    return {
      getByName: vi.fn(async (name: string) => {
        const m = opts.members.find((x) => x.name === name) ?? opts.members[0]!;
        return { ...baseRow(m), apiKeyRef: null };
      }),
      findByPoolName: vi.fn(async () => opts.members.map(baseRow)),
      resolveApiKey: vi.fn(async () => 'fake-key'),
    } as unknown as LlmService;
  }

  it('chat dispatches to a pool member and persists the reply (pool size N, primary returns)', async () => {
    // Three healthy members; the (random) primary wins on first try.
    // Adapter returns the same canned reply regardless of which member
    // got picked because in this test we don't differentiate by name —
    // we just assert that dispatch goes through and the agent gets a
    // reply. Per-member assertion is covered by the failover test below.
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('hello from pool')]);
    const svc = new ChatService(
      mockAgents(),
      mockLlmsPool({
        pinnedName: 'qwen-prod-1',
        poolName: 'qwen-pool',
        members: [
          { name: 'qwen-prod-1' },
          { name: 'qwen-prod-2' },
          { name: 'qwen-prod-3' },
        ],
      }),
      adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('hello from pool');
    expect(adapter.infer).toHaveBeenCalledTimes(1);
  });

  it('chat fails over to the next pool member when the first throws on dispatch', async () => {
    // 3 members; first 2 throw, 3rd succeeds. Verify exactly 3 dispatches
    // and the final reply propagates.
    const chatRepo = mockChatRepo();
    let call = 0;
    const adapter: LlmAdapter = {
      kind: 'flaky',
      infer: vi.fn(async () => {
        call += 1;
        if (call < 3) throw new Error(`transport-error-${String(call)}`);
        return chatCompletion('survived to the third try').body !== undefined
          ? { status: 200, body: chatCompletion('survived to the third try').body }
          : (() => { throw new Error('unreachable'); })();
      }),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(),
      mockLlmsPool({
        pinnedName: 'qwen-prod-1',
        poolName: 'qwen-pool',
        members: [
          { name: 'qwen-prod-1' },
          { name: 'qwen-prod-2' },
          { name: 'qwen-prod-3' },
        ],
      }),
      adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('survived to the third try');
    expect(adapter.infer).toHaveBeenCalledTimes(3);
  });

  it('chat throws when every pool member throws (exhausted)', async () => {
    const chatRepo = mockChatRepo();
    const adapter: LlmAdapter = {
      kind: 'all-broken',
      infer: vi.fn(async () => { throw new Error('transport-down'); }),
      stream: async function*() { yield { data: '[DONE]', done: true }; },
    };
    const svc = new ChatService(
      mockAgents(),
      mockLlmsPool({
        pinnedName: 'qwen-prod-1',
        poolName: 'qwen-pool',
        members: [{ name: 'qwen-prod-1' }, { name: 'qwen-prod-2' }],
      }),
      adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
      .rejects.toThrow(/transport-down/);
    expect(adapter.infer).toHaveBeenCalledTimes(2);
  });

  it('chat refuses with 404 when every pool member is inactive', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('should never run')]);
    const svc = new ChatService(
      mockAgents(),
      mockLlmsPool({
        pinnedName: 'qwen-prod-1',
        poolName: 'qwen-pool',
        members: [
          { name: 'qwen-prod-1', status: 'inactive' },
          { name: 'qwen-prod-2', status: 'inactive' },
        ],
      }),
      adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    await expect(svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }))
      .rejects.toThrow(/No active Llm in pool/);
    expect(adapter.infer).not.toHaveBeenCalled();
  });

  it('chat picks a healthy sibling when the pinned Llm is itself inactive', async () => {
    // The agent pins to qwen-prod-1 which is inactive. qwen-prod-2 is
    // active. Pool dispatch must skip the dead pinned row and use the
    // sibling instead — that's the whole point of v4.
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('via sibling')]);
    const svc = new ChatService(
      mockAgents(),
      mockLlmsPool({
        pinnedName: 'qwen-prod-1',
        poolName: 'qwen-pool',
        members: [
          { name: 'qwen-prod-1', status: 'inactive' },
          { name: 'qwen-prod-2', status: 'active' },
        ],
      }),
      adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
    );
    const result = await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    expect(result.assistant).toBe('via sibling');
    expect(adapter.infer).toHaveBeenCalledTimes(1);
  });

  // Regression: per-agent maxIterations override + clamp.
  // Found by /gstack-review on 2026-04-25.
  // Without the clamp, a hostile agent definition with `extras.maxIterations:1000000`
  // could spin the loop into a near-infinite tool-call burn.
  it('per-agent extras.maxIterations clamps below default and refuses absurd values', async () => {
    const chatRepo = mockChatRepo();
    const tools = mockTools({
      listTools: vi.fn(async () => [{
        name: `g${TOOL_NAME_SEPARATOR}t`, description: '', parameters: {},
      }]),
      callTool: vi.fn(async () => ({})),
    });
    // Agent with maxIterations=2 — only 2 tool-call rounds allowed before bail.
    const agentsLowCap = {
      getByName: vi.fn(async () => ({
        id: 'agent-low', name: 'low', description: '', systemPrompt: '',
        llm: { id: 'llm-1', name: 'qwen3-thinking' },
        project: { id: 'proj-1', name: 'mcpctl-dev' },
        proxyModelName: null, defaultParams: {},
        extras: { maxIterations: 2 },
        ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
      })),
    } as unknown as AgentService;
    const adapter = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
    const svc = new ChatService(
      agentsLowCap, mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), tools,
    );
    await expect(svc.chat({
      agentName: 'low', userMessage: 'spin', ownerId: 'owner-1',
    })).rejects.toThrow(/exceeded 2 iterations/);

    // Hostile agent with maxIterations=1000000 — must clamp to 50, not iterate forever.
    const agentsHostile = {
      getByName: vi.fn(async () => ({
        id: 'agent-bad', name: 'bad', description: '', systemPrompt: '',
        llm: { id: 'llm-1', name: 'qwen3-thinking' },
        project: { id: 'proj-1', name: 'mcpctl-dev' },
        proxyModelName: null, defaultParams: {},
        extras: { maxIterations: 1_000_000 },
        ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW,
      })),
    } as unknown as AgentService;
    const adapter2 = scriptedAdapter([toolCall(`g${TOOL_NAME_SEPARATOR}t`, {})]);
    const chatRepo2 = mockChatRepo();
    const svc2 = new ChatService(
      agentsHostile, mockLlms(), adapterRegistry(adapter2),
      chatRepo2, mockPromptRepo(), tools,
    );
    await expect(svc2.chat({
      agentName: 'bad', userMessage: 'spin', ownerId: 'owner-1',
    })).rejects.toThrow(/exceeded 50 iterations/);
  });

  // Regression: thread message reads must enforce ownership.
  // Found by /gstack-review on 2026-04-25.
  // Without this, any caller with `view:agents` could read another user's thread
  // by guessing/learning the threadId (CUIDs leak through SSE chunks + tool _meta).
  it('listMessages refuses a thread owned by another user (404, not 403, to avoid id-enumeration)', async () => {
    const chatRepo = mockChatRepo();
    // Pre-seed a thread owned by 'alice'
    await chatRepo.createThread({ agentId: 'agent-x', ownerId: 'alice' });
    const aliceThread = chatRepo._threads[0]!;
    await chatRepo.appendMessage({
      threadId: aliceThread.id,
      role: 'user',
      content: 'private to alice',
    });

    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(scriptedAdapter([chatCompletion('ok')])),
      chatRepo, mockPromptRepo(), mockTools(),
    );

    // Bob requests Alice's thread by id — must 404.
    await expect(svc.listMessages(aliceThread.id, 'bob'))
      .rejects.toThrow(/not found/i);

    // Alice gets her own messages.
    const aliceMessages = await svc.listMessages(aliceThread.id, 'alice');
    expect(aliceMessages.map((m) => m.content)).toEqual(['private to alice']);

    // Genuinely missing thread — same 404 shape (no oracle leak).
    await expect(svc.listMessages('cnonexistent000000000000000', 'alice'))
      .rejects.toThrow(/not found/i);
  });

  // ── Agent-direct prompts + personality overlay (Stage 3 system block) ──

  it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const prompts: Prompt[] = [
      // Project prompt
      {
        id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
        projectId: 'proj-1', agentId: null, priority: 5, summary: null,
        chapters: null, linkTarget: null, version: 1,
        createdAt: NOW, updatedAt: NOW,
      },
      // Agent-direct prompt
      {
        id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT',
        projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null,
        chapters: null, linkTarget: null, version: 1,
        createdAt: NOW, updatedAt: NOW,
      },
    ];
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(prompts), mockTools(),
    );
    await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
    const text = sys?.content as string;
    expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT'));
    expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT'));
  });

  it('appends personality-bound prompts after project prompts when --personality is passed', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const projectPrompt: Prompt = {
      id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT',
      projectId: 'proj-1', agentId: null, priority: 5, summary: null,
      chapters: null, linkTarget: null, version: 1,
      createdAt: NOW, updatedAt: NOW,
    };
    const personalityPrompt: Prompt = {
      id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT',
      projectId: null, agentId: null, priority: 5, summary: null,
      chapters: null, linkTarget: null, version: 1,
      createdAt: NOW, updatedAt: NOW,
    };

    const personalities = mockPersonalityRepo({
      'pers-grumpy': {
        personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }),
        bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
      },
    }, [projectPrompt, personalityPrompt]);

    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(),
      personalities,
    );
    await svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
      personalityName: 'grumpy',
    });
    const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
    const text = sys?.content as string;
    expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT'));
  });

  it('falls back to agent.defaultPersonality when --personality is omitted', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const personalityPrompt: Prompt = {
      id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT',
      projectId: null, agentId: null, priority: 5, summary: null,
      chapters: null, linkTarget: null, version: 1,
      createdAt: NOW, updatedAt: NOW,
    };
    const personalities = mockPersonalityRepo({
      'pers-default': {
        personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }),
        bindings: [{ promptId: personalityPrompt.id, priority: 5 }],
      },
    }, [personalityPrompt]);

    const svc = new ChatService(
      mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }),
      mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo([personalityPrompt]), mockTools(),
      personalities,
    );
    await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
    expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT');
  });

  it('throws when --personality references a name the agent does not own', async () => {
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const personalities = mockPersonalityRepo({});

    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo(), mockTools(),
      personalities,
    );
    await expect(svc.chat({
      agentName: 'reviewer',
      userMessage: 'hi',
      ownerId: 'owner-1',
      personalityName: 'ghost',
    })).rejects.toThrow(/Personality not found/);
  });

  it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => {
    // Regression guard: backwards-compatible by construction.
    const chatRepo = mockChatRepo();
    const adapter = scriptedAdapter([chatCompletion('ok')]);
    const inferSpy = adapter.infer as ReturnType<typeof vi.fn>;
    const projectPrompt: Prompt = {
      id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT',
      projectId: 'proj-1', agentId: null, priority: 5, summary: null,
      chapters: null, linkTarget: null, version: 1,
      createdAt: NOW, updatedAt: NOW,
    };
    const svc = new ChatService(
      mockAgents(), mockLlms(), adapterRegistry(adapter),
      chatRepo, mockPromptRepo([projectPrompt]), mockTools(),
    );
    await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' });
    const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system');
    const text = sys?.content as string;
    expect(text).toContain('You are a helpful agent.');
    expect(text).toContain('ONLY_PROJECT_TEXT');
  });
});

// ── Helpers for personality-overlay tests ──

function makePersonality(overrides: Partial<Personality> = {}): Personality {
  return {
    id: `pers-${Math.random().toString(36).slice(2, 8)}`,
    name: 'p',
    description: '',
    agentId: 'agent-reviewer',
    priority: 5,
    createdAt: NOW,
    updatedAt: NOW,
    ...overrides,
  };
}

interface MockPersonalityFixture {
  personality: Personality;
  bindings: Array<{ promptId: string; priority: number }>;
}

function mockPersonalityRepo(
  fixtures: Record<string, MockPersonalityFixture>,
  prompts: Prompt[] = [],
): IPersonalityRepository {
  const byId = new Map<string, MockPersonalityFixture>(Object.entries(fixtures));
  const promptsById = new Map<string, Prompt>(prompts.map((p) => [p.id, p]));
  return {
    findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)),
    findByAgent: vi.fn(async (agentId: string) =>
      [...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)),
    findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null),
    findByNameAndAgent: vi.fn(async (name: string, agentId: string) => {
      for (const f of byId.values()) {
        if (f.personality.name === name && f.personality.agentId === agentId) {
          return f.personality;
        }
      }
      return null;
    }),
    create: vi.fn(),
    update: vi.fn(),
    delete: vi.fn(),
    listPrompts: vi.fn(async (personalityId: string) => {
      const fixture = byId.get(personalityId);
      if (!fixture) return [];
      return fixture.bindings.map<PersonalityPrompt & { prompt: Prompt }>((b) => ({
        id: `bind-${b.promptId}`,
        personalityId,
        promptId: b.promptId,
        priority: b.priority,
        createdAt: NOW,
        prompt: promptsById.get(b.promptId) ?? ({
          id: b.promptId, name: 'p', content: '',
          projectId: null, agentId: null, priority: b.priority,
          summary: null, chapters: null, linkTarget: null, version: 1,
          createdAt: NOW, updatedAt: NOW,
        } as Prompt),
      }));
    }),
    attachPrompt: vi.fn(),
    detachPrompt: vi.fn(),
    findBinding: vi.fn(async () => null),
  };
}

// ── Failover chain + "which model answered" reporting ──
describe('ChatService — failover chain', () => {
  const NOW2 = new Date();
  function llmRow(name: string, model: string, fallbacks: string[] = []): Record<string, unknown> {
    return {
      id: `id-${name}`, name, type: 'openai', model, url: '', tier: 'fast', description: '',
      apiKeySecretId: null, apiKeySecretKey: null,
      extraConfig: fallbacks.length > 0 ? { fallbacks } : {},
      poolName: null, kind: 'public', providerSessionId: null, status: 'active',
      lastHeartbeatAt: null, inactiveSince: null, version: 1, createdAt: NOW2, updatedAt: NOW2,
    };
  }
  function failoverLlms(): LlmService {
    const rows: Record<string, Record<string, unknown>> = {
      'primary-llm': llmRow('primary-llm', 'primary-model', ['fallback-llm']),
      'fallback-llm': llmRow('fallback-llm', 'fallback-model'),
    };
    return {
      getByName: vi.fn(async (name: string) => ({ ...rows[name], apiKeyRef: null })),
      findByPoolName: vi.fn(async (pool: string) => [rows[pool]]),
      resolveApiKey: vi.fn(async () => 'k'),
    } as unknown as LlmService;
  }
  function agentPinnedTo(llmName: string): AgentService {
    return {
      getByName: vi.fn(async (name: string) => ({
        id: `agent-${name}`, name, description: '', systemPrompt: 'sys',
        llm: { id: 'x', name: llmName }, project: null, defaultPersonality: null,
        proxyModelName: null, defaultParams: {}, extras: {}, ownerId: 'o', version: 1, createdAt: NOW2, updatedAt: NOW2,
      })),
    } as unknown as AgentService;
  }
  const status400 = (): NonStreamingResult => ({ status: 400, body: { error: 'model not served' } });

  it('fails over to the fallback Llm on a primary 400 and reports the answering model', async () => {
    const adapter = scriptedAdapter([status400(), chatCompletion('fallback answer')]);
    const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
    const res = await svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' });
    expect(res.assistant).toBe('fallback answer');
    expect(res.failedOver).toBe(true);
    expect(res.llm).toBe('fallback-llm');
    expect(res.model).toBe('fallback-model');
  });

  it('reports the primary model + failedOver=false when the primary answers', async () => {
    const adapter = scriptedAdapter([chatCompletion('primary answer')]);
    const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
    const res = await svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' });
    expect(res.assistant).toBe('primary answer');
    expect(res.failedOver).toBe(false);
    expect(res.llm).toBe('primary-llm');
    expect(res.model).toBe('primary-model');
  });

  it('throws a clear aggregated error naming the model when every candidate fails', async () => {
    const adapter = scriptedAdapter([status400(), status400()]);
    const svc = new ChatService(agentPinnedTo('primary-llm'), failoverLlms(), adapterRegistry(adapter), mockChatRepo(), mockPromptRepo(), mockTools());
    await expect(svc.chat({ agentName: 'a', userMessage: 'hi', ownerId: 'o' })).rejects.toThrow(/HTTP 400/);
  });
});
