/**
 * ChatService — orchestrates an agent's chat turn end-to-end.
 *
 * For one inbound chat call:
 *  1. Resolve the agent → its Llm and (optional) Project.
 *  2. Build messages: merged system block (agent.systemPrompt + project
 *     Prompts joined by priority desc) + persisted thread history + new
 *     user turn. Persist the user turn (status:complete) up front.
 *  3. Enumerate tools from the project's MCP servers via the injected
 *     ToolDispatcher and translate to OpenAI function-tool format.
 *  4. Loop (cap = MAX_ITERATIONS) calling the adapter:
 *      - if the model returns text → persist as assistant (complete), end.
 *      - if it returns tool_calls → persist assistant turn (pending) with
 *        the tool_calls JSON; for each call, dispatch through the
 *        ToolDispatcher; persist a tool turn with the result; flip the
 *        assistant turn to complete; loop.
 *  5. On any exception, mark all `pending` rows in the thread as `error`
 *     and surface the error to the caller. No big DB transaction wraps the
 *     loop because tool calls can take minutes.
 *
 * Per-call params merge resolution: request body → agent.defaultParams →
 * adapter default. `extra` is forwarded as-is for provider-specific knobs.
 */
import type { ChatMessage } from '@prisma/client';
import type { AgentService } from './agent.service.js';
import type { LlmService } from './llm.service.js';
import { effectivePoolName } from './llm.service.js';
import type { LlmAdapterRegistry } from './llm/dispatcher.js';
import type {
  IChatRepository,
  ChatRole,
} from '../repositories/chat.repository.js';
import type { IPromptRepository } from '../repositories/prompt.repository.js';
import type { IPersonalityRepository } from '../repositories/personality.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IVirtualLlmService } from './virtual-llm.service.js';
import type { RbacService } from './rbac.service.js';
import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js';
import type { AgentChatParams } from '../validation/agent.schema.js';
import { NotFoundError } from './mcp-server.service.js';

/** Minimal secret-read surface the project-chat `get_secret` tool needs. */
export interface ChatSecretResolver {
  resolve(secretName: string, key: string): Promise<string>;
}

/** Wire name of the gated virtual secret-read tool offered in project chat. */
export const SECRET_TOOL_SERVER = 'secrets';
export const SECRET_TOOL_NAME = 'get_secret';

export const TOOL_NAME_SEPARATOR = '__';
/** Default tool-loop cap. Per-agent override via `agent.extras.maxIterations`, clamped to MIN..MAX. */
export const MAX_ITERATIONS = 12;
const MIN_ITERATIONS_CAP = 1;
const MAX_ITERATIONS_CAP = 50;

/**
 * Resolve the loop cap for this turn:
 *   agent.extras.maxIterations → clamp(MIN_ITERATIONS_CAP, MAX_ITERATIONS_CAP) →
 *   fallback to MAX_ITERATIONS default.
 *
 * The clamp is the soft-DoS guard: a hostile agent definition can't pick a
 * thousand-iteration cap, even with `create:agents` permission.
 */
function resolveMaxIterations(extras: Record<string, unknown> | null | undefined): number {
  const raw = extras?.['maxIterations'];
  if (typeof raw !== 'number' || !Number.isFinite(raw)) return MAX_ITERATIONS;
  const truncated = Math.trunc(raw);
  if (truncated < MIN_ITERATIONS_CAP) return MIN_ITERATIONS_CAP;
  if (truncated > MAX_ITERATIONS_CAP) return MAX_ITERATIONS_CAP;
  return truncated;
}

/** Project-scoped tool surface the chat loop calls into. Stub-friendly. */
export interface ChatTool {
  /** Wire format: `<serverName>${TOOL_NAME_SEPARATOR}<toolName>`. */
  name: string;
  description: string;
  parameters: Record<string, unknown>;
}

export interface ChatToolDispatcher {
  /** List tools available to an agent's project. Empty if no project. */
  listTools(projectId: string | null): Promise<ChatTool[]>;
  /** Execute a tool call. Throws on error. */
  callTool(args: {
    projectId: string;
    serverName: string;
    toolName: string;
    args: Record<string, unknown>;
  }): Promise<unknown>;
}

export interface ChatStreamChunk {
  /**
   * Chunk type:
   *   - text: assistant text delta
   *   - thinking: reasoning_content delta (qwen3-thinking, o1, deepseek-reasoner
   *     etc. emit reasoning before content; surface it so the REPL can show
   *     "the model is thinking" instead of going silent for 30-90s)
   *   - tool_call: model decided to call a tool
   *   - tool_result: tool dispatch outcome
   *   - final: terminal turn (carries threadId/turnIndex)
   *   - error: fatal error in the loop
   */
  type: 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'final' | 'error';
  delta?: string;
  toolName?: string;
  args?: Record<string, unknown>;
  ok?: boolean;
  threadId?: string;
  turnIndex?: number;
  message?: string;
  /** On `final`: which Llm/model produced the reply, and whether it failed over. */
  llm?: string;
  model?: string;
  failedOver?: boolean;
}

/**
 * v4: a single resolvable pool member. The chat dispatcher iterates a
 * non-empty list of these on transport-level failure (network, virtual
 * publisher disconnect, etc.) until one succeeds or the list is exhausted.
 * For pool size 1 (no `poolName` set on the pinned Llm) the list has one
 * entry and behavior is identical to pre-v4.
 */
export interface PoolCandidate {
  llmName: string;
  llmType: string;
  llmKind: 'public' | 'virtual';
  modelOverride: string;
  url: string;
  apiKey: string;
  extraConfig: Record<string, unknown>;
}

export interface ChatRequestArgs {
  agentName: string;
  threadId?: string;
  userMessage?: string;
  /** Optional full-history override; if set, threadId history is ignored. */
  messagesOverride?: OpenAiMessage[];
  ownerId: string;
  params?: AgentChatParams;
  /**
   * Personality overlay for this turn. If set, the personality's bound
   * prompts are appended to the system block (additive). If unset, falls
   * back to `agent.defaultPersonalityId`. If neither is present, today's
   * behavior (no personality overlay) holds.
   */
  personalityName?: string;
}

export interface ChatResult {
  threadId: string;
  assistant: string;
  turnIndex: number;
  /** Which Llm actually produced the reply (after any failover). */
  llm: string;
  /** The model string that answered (e.g. 'qwen3-thinking' or 'claude-opus-4-...'). */
  model: string;
  /** True when the reply came from a fallback rather than the primary Llm. */
  failedOver: boolean;
}

/**
 * Project-scoped chat: `mcpctl chat --project <name>`. Same turn loop as an
 * agent, but the LLM/system-block/tools are resolved from the Project instead
 * of a named Agent. `allowSecrets` gates the virtual `get_secret` tool.
 */
export interface ChatProjectRequestArgs {
  projectName: string;
  threadId?: string;
  userMessage?: string;
  messagesOverride?: OpenAiMessage[];
  ownerId: string;
  params?: AgentChatParams;
  allowSecrets?: boolean;
}

/** The fully-resolved per-turn context both the agent and project paths share. */
interface PreparedChatContext {
  threadId: string;
  history: OpenAiMessage[];
  systemBlock: string;
  /** Ordered dispatch list: the primary Llm's pool first, then each fallback
   * Llm's pool (from the primary's extraConfig.fallbacks). The loop tries them
   * in order and fails over on error/4xx/no-completion. */
  poolCandidates: PoolCandidate[];
  /** Name of the intended primary Llm (to detect + report a failover). */
  primaryLlm: string;
  mergedParams: AgentChatParams;
  toolList: ChatTool[];
  projectId: string | null;
  startingTurnIndex: number;
  maxIterations: number;
  /** When true, `dispatchTool` will honor the virtual `secrets__get_secret` call. */
  allowSecrets: boolean;
}

export class ChatService {
  constructor(
    private readonly agents: AgentService,
    private readonly llms: LlmService,
    private readonly adapters: LlmAdapterRegistry,
    private readonly chatRepo: IChatRepository,
    private readonly promptRepo: IPromptRepository,
    private readonly tools: ChatToolDispatcher,
    private readonly personalities?: IPersonalityRepository,
    /**
     * v3: when an Agent is pinned to a `kind=virtual` Llm, inference is
     * relayed via this service rather than an HTTP adapter (the virtual
     * row has no upstream URL). Optional so older test wirings still
     * compile; in those tests the chat path will refuse virtual Llms
     * with a clear error.
     */
    private readonly virtualLlms?: IVirtualLlmService,
    /** Project-chat deps (optional so agent-only test wirings still compile). */
    private readonly projects?: IProjectRepository,
    private readonly rbac?: RbacService,
    private readonly secrets?: ChatSecretResolver,
  ) {}

  async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> {
    const agent = await this.agents.getByName(agentName);
    const thread = await this.chatRepo.createThread({
      agentId: agent.id,
      ownerId,
      ...(title !== undefined ? { title } : {}),
    });
    return { id: thread.id };
  }

  async listThreads(agentName: string, ownerId?: string): Promise<Array<{ id: string; title: string; lastTurnAt: Date; createdAt: Date }>> {
    const agent = await this.agents.getByName(agentName);
    const rows = await this.chatRepo.listThreadsByAgent(agent.id, ownerId);
    return rows.map((r) => ({ id: r.id, title: r.title, lastTurnAt: r.lastTurnAt, createdAt: r.createdAt }));
  }

  async listMessages(threadId: string, ownerId: string): Promise<ChatMessage[]> {
    // Owner check guards `view:agents` from leaking another user's thread by
    // ID. Thread IDs are CUIDs (hard to guess) but they leak through SSE
    // `final` chunks, the agents-plugin tool _meta, and several response
    // bodies, so id-knowledge is not a security boundary on its own. Return
    // 404 (not 403) on mismatch to avoid id-enumeration via differential
    // status codes.
    const thread = await this.chatRepo.findThread(threadId);
    if (thread === null) throw new NotFoundError(`Thread not found: ${threadId}`);
    if (thread.ownerId !== ownerId) throw new NotFoundError(`Thread not found: ${threadId}`);
    return this.chatRepo.listMessages(threadId);
  }

  /** Non-streaming chat with an agent. */
  async chat(args: ChatRequestArgs): Promise<ChatResult> {
    return this.runChatLoop(await this.prepareContext(args));
  }

  /** Non-streaming chat scoped to a project. */
  async chatProject(args: ChatProjectRequestArgs): Promise<ChatResult> {
    return this.runChatLoop(await this.prepareProjectContext(args));
  }

  /** Shared non-streaming turn loop. Persists rows + returns the final text. */
  private async runChatLoop(ctx: PreparedChatContext): Promise<ChatResult> {
    let assistantFinal = '';
    let lastTurnIndex = ctx.startingTurnIndex;
    let winner: PoolCandidate | undefined;
    try {
      for (let i = 0; i < ctx.maxIterations; i += 1) {
        const result = await this.runOneInference(ctx);
        winner = result.candidate;
        const choice = extractChoice(result.body);
        if (choice === null) {
          // Surface the upstream body (LiteLLM/vLLM names the model + reason,
          // e.g. "model deepseek-v4-flash not found") instead of a vague
          // "no choice" — the caller needs to know WHICH model failed and why.
          const detail = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
          throw new Error(`LLM returned no completion (HTTP ${String(result.status)}): ${detail.slice(0, 400)}`);
        }
        if (choice.tool_calls !== undefined && choice.tool_calls.length > 0) {
          // Tool turns: keep `content` literal — even if empty — because the
          // OpenAI tool-use protocol expects the assistant message to carry
          // its tool_calls separately from any free-form text. Surfacing
          // reasoning here would confuse downstream tool dispatchers.
          const assistantTurn = await this.chatRepo.appendMessage({
            threadId: ctx.threadId,
            role: 'assistant',
            content: choice.content ?? '',
            toolCalls: choice.tool_calls.map((c) => ({
              id: c.id,
              name: c.function.name,
              arguments: safeParseJson(c.function.arguments),
            })),
            status: 'pending',
          });
          ctx.history.push({
            role: 'assistant',
            content: choice.content ?? '',
            tool_calls: choice.tool_calls,
          });
          for (const call of choice.tool_calls) {
            const toolResult = await this.dispatchTool(call.function.name, call.function.arguments, ctx.projectId, ctx.allowSecrets);
            const resultMsg = await this.chatRepo.appendMessage({
              threadId: ctx.threadId,
              role: 'tool',
              content: typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult),
              toolCallId: call.id,
            });
            lastTurnIndex = resultMsg.turnIndex;
            ctx.history.push({
              role: 'tool',
              content: typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult),
              tool_call_id: call.id,
            });
          }
          await this.chatRepo.updateStatus(assistantTurn.id, 'complete');
          continue;
        }
        // Terminal text turn. Use pickAssistantText so thinking models that
        // produced only reasoning_content still yield a usable answer (with
        // a truncation marker when finish_reason indicates max_tokens
        // cut-off). Empty body remains empty and bubbles up unchanged.
        const assistantText = pickAssistantText(choice);
        const finalMsg = await this.chatRepo.appendMessage({
          threadId: ctx.threadId,
          role: 'assistant',
          content: assistantText,
        });
        assistantFinal = assistantText;
        lastTurnIndex = finalMsg.turnIndex;
        await this.chatRepo.touchThread(ctx.threadId);
        return {
          threadId: ctx.threadId,
          assistant: assistantFinal,
          turnIndex: lastTurnIndex,
          llm: winner?.llmName ?? ctx.primaryLlm,
          model: winner?.modelOverride ?? '',
          failedOver: winner !== undefined && winner.llmName !== ctx.primaryLlm,
        };
      }
      throw new Error(`Chat loop exceeded ${String(ctx.maxIterations)} iterations without a terminal turn`);
    } catch (err) {
      await this.chatRepo.markPendingAsError(ctx.threadId);
      throw err;
    }
  }

  /** Streaming chat with an agent. Yields text deltas + tool events. */
  async *chatStream(args: ChatRequestArgs): AsyncGenerator<ChatStreamChunk> {
    yield* this.runChatStreamLoop(await this.prepareContext(args));
  }

  /** Streaming chat scoped to a project. */
  async *chatProjectStream(args: ChatProjectRequestArgs): AsyncGenerator<ChatStreamChunk> {
    yield* this.runChatStreamLoop(await this.prepareProjectContext(args));
  }

  /** Shared streaming turn loop. Persists rows in lockstep. */
  private async *runChatStreamLoop(ctx: PreparedChatContext): AsyncGenerator<ChatStreamChunk> {
    const winnerRef: { candidate: PoolCandidate | null } = { candidate: null };
    try {
      for (let i = 0; i < ctx.maxIterations; i += 1) {
        // `reasoning` is accumulated alongside `content` so we can fall back
        // to it when the model produces no `content` (thinking models with a
        // tight max_tokens, or providers that don't separate the two).
        const accumulated: {
          content: string;
          reasoning: string;
          toolCalls: Array<{ id: string; name: string; argumentsJson: string }>;
        } = {
          content: '',
          reasoning: '',
          toolCalls: [],
        };
        let finishReason: string | null = null;
        for await (const chunk of this.streamInference(ctx, winnerRef)) {
          if (chunk.done === true) break;
          if (chunk.data === '[DONE]') break;
          const evt = parseStreamingChunk(chunk.data);
          if (evt === null) continue;
          if (evt.contentDelta !== undefined) {
            accumulated.content += evt.contentDelta;
            yield { type: 'text', delta: evt.contentDelta };
          }
          if (evt.reasoningDelta !== undefined) {
            // Streamed live so the REPL can show progress while the model
            // thinks. Also accumulated so a thinking-only response (no
            // `content`) still produces a non-empty persisted assistant
            // turn — see the fallback at the end of this loop iteration.
            accumulated.reasoning += evt.reasoningDelta;
            yield { type: 'thinking', delta: evt.reasoningDelta };
          }
          if (evt.toolCallDeltas !== undefined) {
            for (const td of evt.toolCallDeltas) {
              const slot = (accumulated.toolCalls[td.index] ??= { id: '', name: '', argumentsJson: '' });
              if (td.id !== undefined) slot.id = td.id;
              if (td.name !== undefined) slot.name = td.name;
              if (td.argumentsDelta !== undefined) slot.argumentsJson += td.argumentsDelta;
            }
          }
          if (evt.finishReason !== null && evt.finishReason !== undefined) {
            finishReason = evt.finishReason;
          }
        }

        if (accumulated.toolCalls.length > 0 && finishReason === 'tool_calls') {
          const assistantTurn = await this.chatRepo.appendMessage({
            threadId: ctx.threadId,
            role: 'assistant',
            content: accumulated.content,
            toolCalls: accumulated.toolCalls.map((c) => ({
              id: c.id,
              name: c.name,
              arguments: safeParseJson(c.argumentsJson),
            })),
            status: 'pending',
          });
          ctx.history.push({
            role: 'assistant',
            content: accumulated.content,
            tool_calls: accumulated.toolCalls.map((c) => ({
              id: c.id,
              type: 'function',
              function: { name: c.name, arguments: c.argumentsJson },
            })),
          });
          for (const call of accumulated.toolCalls) {
            yield { type: 'tool_call', toolName: call.name, args: safeParseJson(call.argumentsJson) as Record<string, unknown> };
            try {
              const result = await this.dispatchTool(call.name, call.argumentsJson, ctx.projectId, ctx.allowSecrets);
              const resultStr = typeof result === 'string' ? result : JSON.stringify(result);
              await this.chatRepo.appendMessage({
                threadId: ctx.threadId,
                role: 'tool',
                content: resultStr,
                toolCallId: call.id,
              });
              ctx.history.push({ role: 'tool', content: resultStr, tool_call_id: call.id });
              yield { type: 'tool_result', toolName: call.name, ok: true };
            } catch (toolErr) {
              const errMsg = (toolErr as Error).message;
              await this.chatRepo.appendMessage({
                threadId: ctx.threadId,
                role: 'tool',
                content: `error: ${errMsg}`,
                toolCallId: call.id,
                status: 'error',
              });
              ctx.history.push({ role: 'tool', content: `error: ${errMsg}`, tool_call_id: call.id });
              yield { type: 'tool_result', toolName: call.name, ok: false };
            }
          }
          await this.chatRepo.updateStatus(assistantTurn.id, 'complete');
          continue;
        }

        // Fall back to reasoning when the model emitted only thinking
        // output. Mirrors pickAssistantText() in the non-streaming path —
        // same situation (thinking model + tight max_tokens, or a provider
        // that bundles the answer into reasoning_content).
        const persistedContent = pickAssistantText({
          content: accumulated.content.length > 0 ? accumulated.content : null,
          ...(accumulated.reasoning.length > 0 ? { reasoning: accumulated.reasoning } : {}),
          finishReason,
        });
        // If we synthesized text from reasoning, yield it as a final `text`
        // delta so the client's stdout matches what the thread persists.
        // Without this, the REPL would show only `thinking` deltas (which
        // the CLI writes to stderr) and stdout would be empty for any
        // thinking-only response.
        if (accumulated.content.length === 0 && persistedContent.length > 0) {
          yield { type: 'text', delta: persistedContent };
        }
        const finalMsg = await this.chatRepo.appendMessage({
          threadId: ctx.threadId,
          role: 'assistant',
          content: persistedContent,
        });
        await this.chatRepo.touchThread(ctx.threadId);
        yield {
          type: 'final',
          threadId: ctx.threadId,
          turnIndex: finalMsg.turnIndex,
          llm: winnerRef.candidate?.llmName ?? ctx.primaryLlm,
          model: winnerRef.candidate?.modelOverride ?? '',
          failedOver: winnerRef.candidate !== null && winnerRef.candidate.llmName !== ctx.primaryLlm,
        };
        return;
      }
      throw new Error(`Chat loop exceeded ${String(ctx.maxIterations)} iterations without a terminal turn`);
    } catch (err) {
      await this.chatRepo.markPendingAsError(ctx.threadId);
      yield { type: 'error', message: (err as Error).message };
    }
  }

  /**
   * Streaming counterpart of runOneInference. Yields raw OpenAI-style
   * SSE chunks ({data: string; done?: boolean}) regardless of whether
   * we're hitting a public adapter or relaying through VirtualLlmService.
   * The caller's `parseStreamingChunk` already speaks OpenAI shape, so
   * downstream code doesn't need to know which path produced the chunks.
   */
  /**
   * Stream inference with v4 pool failover. We commit to a candidate the
   * moment the first chunk arrives — partial output cannot be re-streamed
   * from a different backend without confusing the caller. Failover
   * therefore covers only "couldn't establish stream" errors (transport
   * failure pre-first-chunk). After the first yield, exceptions propagate.
   */
  private async *streamInference(ctx: {
    poolCandidates: PoolCandidate[];
    history: OpenAiMessage[];
    systemBlock: string;
    toolList: ChatTool[];
    mergedParams: AgentChatParams;
  }, winnerRef?: { candidate: PoolCandidate | null }): AsyncGenerator<{ data: string; done?: boolean }> {
    let lastErr: Error | null = null;
    for (const c of ctx.poolCandidates) {
      try {
        // Record the candidate we're committing to; if it throws before its
        // first chunk the loop overwrites this on the next iteration, so after
        // a successful stream this holds the Llm that actually answered.
        if (winnerRef) winnerRef.candidate = c;
        yield* this.streamOneCandidate(c, ctx);
        return;
      } catch (err) {
        lastErr = err as Error;
        // Try the next pool member only if no chunk has been yielded yet.
        // streamOneCandidate throws *before* its first yield on dispatch
        // failure; once it begins yielding, any error inside it propagates
        // out of this `for` loop because the caller has already started
        // consuming the stream.
      }
    }
    throw lastErr ?? new Error('chat stream dispatch exhausted: no pool candidates');
  }

  private async *streamOneCandidate(
    candidate: PoolCandidate,
    ctx: {
      history: OpenAiMessage[];
      systemBlock: string;
      toolList: ChatTool[];
      mergedParams: AgentChatParams;
    },
  ): AsyncGenerator<{ data: string; done?: boolean }> {
    if (candidate.llmKind !== 'virtual') {
      const adapter = this.adapters.get(candidate.llmType);
      yield* adapter.stream({
        body: { ...this.buildBody({ ...ctx, modelOverride: candidate.modelOverride }), stream: true },
        modelOverride: candidate.modelOverride,
        apiKey: candidate.apiKey,
        url: candidate.url,
        extraConfig: candidate.extraConfig,
      });
      return;
    }
    if (this.virtualLlms === undefined) {
      throw new Error(
        'virtualLlms dispatcher not wired into ChatService — cannot stream chat with kind=virtual Llm',
      );
    }
    // Bridge VirtualLlmService's onChunk callback API to an async
    // iterator. Chunks land on the queue from the SSE relay; the
    // generator drains them in order. ref.done resolves when the
    // publisher emits its `[DONE]` marker.
    //
    // failFast: true — the chat dispatcher's pool failover loop relies
    // on a fast "transport error" surfacing so it can iterate to the
    // next candidate. Without it, a downed pool member would queue the
    // task and the loop would wait 10 min before trying the next one.
    const ref = await this.virtualLlms.enqueueInferTask(
      candidate.llmName,
      { ...this.buildBody({ ...ctx, modelOverride: candidate.modelOverride }), stream: true },
      true,
      { failFast: true },
    );
    const queue: Array<{ data: string; done?: boolean }> = [];
    let resolveTick: (() => void) | null = null;
    const wake = (): void => {
      const r = resolveTick;
      resolveTick = null;
      if (r !== null) r();
    };
    const unsubscribe = ref.onChunk((c) => { queue.push(c); wake(); });
    let finished = false;
    let failure: Error | null = null;
    ref.done.then(() => { finished = true; wake(); }).catch((err: Error) => { failure = err; finished = true; wake(); });

    try {
      while (true) {
        while (queue.length > 0) {
          const c = queue.shift()!;
          yield c;
          if (c.done === true) return;
        }
        if (finished) {
          if (failure !== null) throw failure;
          return;
        }
        await new Promise<void>((r) => { resolveTick = r; });
      }
    } finally {
      unsubscribe();
    }
  }

  /**
   * Run a single non-streaming inference iteration with v4 pool failover.
   * We try each pool candidate in order; on dispatch error (transport
   * failure, virtual publisher disconnect) we move to the next member.
   * Auth/4xx errors that the adapter surfaces as a status-code in the
   * result are NOT retried — those would fail identically on a sibling
   * with the same key/model, and the cost of one wasted retry per call
   * is real.
   */
  private async runOneInference(ctx: {
    poolCandidates: PoolCandidate[];
    history: OpenAiMessage[];
    systemBlock: string;
    toolList: ChatTool[];
    mergedParams: AgentChatParams;
  }): Promise<{ status: number; body: unknown; candidate: PoolCandidate }> {
    let lastErr: Error | null = null;
    for (const c of ctx.poolCandidates) {
      try {
        const result = await this.dispatchOneCandidate(c, ctx);
        // Fail over on an upstream error status or an empty/invalid completion —
        // NOT just transport errors. This is what lets a 400 (drifted/down model)
        // fall through to the next candidate and, ultimately, the anthropic fallback.
        if (result.status >= 400 || extractChoice(result.body) === null) {
          const detail = typeof result.body === 'string' ? result.body : JSON.stringify(result.body);
          lastErr = new Error(`LLM '${c.llmName}' (model '${c.modelOverride}') HTTP ${String(result.status)}: ${detail.slice(0, 300)}`);
          continue;
        }
        return { ...result, candidate: c };
      } catch (err) {
        lastErr = new Error(`LLM '${c.llmName}' (model '${c.modelOverride}'): ${(err as Error).message}`);
        // Try the next candidate.
      }
    }
    throw lastErr ?? new Error('chat dispatch exhausted: no candidates');
  }

  private async dispatchOneCandidate(
    candidate: PoolCandidate,
    ctx: {
      history: OpenAiMessage[];
      systemBlock: string;
      toolList: ChatTool[];
      mergedParams: AgentChatParams;
    },
  ): Promise<{ status: number; body: unknown }> {
    if (candidate.llmKind === 'virtual') {
      if (this.virtualLlms === undefined) {
        throw new Error(
          'virtualLlms dispatcher not wired into ChatService — cannot chat with kind=virtual Llm',
        );
      }
      const ref = await this.virtualLlms.enqueueInferTask(
        candidate.llmName,
        this.buildBody({ ...ctx, modelOverride: candidate.modelOverride }),
        false,
        { failFast: true },
      );
      return ref.done;
    }
    const adapter = this.adapters.get(candidate.llmType);
    return adapter.infer({
      body: this.buildBody({ ...ctx, modelOverride: candidate.modelOverride }),
      modelOverride: candidate.modelOverride,
      apiKey: candidate.apiKey,
      url: candidate.url,
      extraConfig: candidate.extraConfig,
    });
  }

  private async prepareContext(args: ChatRequestArgs): Promise<PreparedChatContext> {
    const agent = await this.agents.getByName(args.agentName);
    const pinned = await this.llms.getByName(agent.llm.name);
    const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned);
    if (poolCandidates.length === 0) {
      // Pool exists but every member is inactive (publishers offline,
      // public Llm explicitly disabled, etc.). Surface a clear error
      // before running through the rest of preparation; otherwise the
      // chat loop would hit the dispatcher's "exhausted" branch and the
      // caller would see a less helpful message.
      throw new NotFoundError(
        `No active Llm in pool '${effectivePoolName(pinned)}' (pinned: ${pinned.name})`,
      );
    }

    const threadId = await this.resolveThreadId(args, agent.id);
    const projectId = agent.project?.id ?? null;

    // Project prompts (existing): only those whose projectId actually matches
    // the agent's project — `findAll(projectId)` also returns globals which
    // we exclude here so they don't double-count if a future change adds an
    // explicit "global" injection step.
    const projectPrompts = projectId !== null
      ? await this.promptRepo.findAll(projectId)
      : [];
    const sortedProjectPrompts = [...projectPrompts]
      .filter((p) => p.projectId === projectId)
      .sort((a, b) => b.priority - a.priority);

    // Agent-direct prompts: always-on overlay scoped to this specific agent.
    // Ordered after agent.systemPrompt and BEFORE project prompts so
    // agent-specific tone/guardrails win over project-wide context.
    const agentDirectPrompts = (await this.promptRepo.findByAgent(agent.id))
      .sort((a, b) => b.priority - a.priority);

    // Personality overlay: chooses by request-supplied name first, falling
    // back to the agent's defaultPersonalityId. Without a personality this
    // path is a no-op and the resulting block matches today's behavior.
    const personalityPromptContents = await this.resolvePersonalityPrompts(args, agent);

    const mergedParams: AgentChatParams = {
      ...(agent.defaultParams ?? {}),
      ...(args.params ?? {}),
    };

    const baseSystem = mergedParams.systemOverride ?? agent.systemPrompt;
    const systemBlock = [
      baseSystem,
      ...agentDirectPrompts.map((p) => p.content),
      ...sortedProjectPrompts.map((p) => p.content),
      ...personalityPromptContents,
      mergedParams.systemAppend ?? '',
    ]
      .filter((s) => s.length > 0)
      .join('\n\n');

    const history = args.messagesOverride !== undefined
      ? [...args.messagesOverride]
      : await this.loadHistory(threadId);

    let startingTurnIndex = await this.chatRepo.nextTurnIndex(threadId);
    if (args.userMessage !== undefined && args.messagesOverride === undefined) {
      const userTurn = await this.chatRepo.appendMessage({
        threadId,
        role: 'user',
        content: args.userMessage,
      });
      startingTurnIndex = userTurn.turnIndex;
      history.push({ role: 'user', content: args.userMessage });
    }

    const toolList = await this.tools.listTools(projectId);
    const allowed = mergedParams.tools_allowlist;
    const filteredTools = allowed === undefined
      ? toolList
      : toolList.filter((t) => allowed.includes(t.name));

    return {
      threadId,
      history,
      systemBlock,
      poolCandidates,
      primaryLlm: pinned.name,
      mergedParams,
      toolList: filteredTools,
      projectId,
      startingTurnIndex,
      maxIterations: resolveMaxIterations(agent.extras),
      allowSecrets: false,
    };
  }

  // ── Project-scoped chat ──────────────────────────────────────────────────

  /** Create a new project-scoped thread (owned by the caller). */
  async createProjectThread(projectName: string, ownerId: string, title?: string): Promise<{ id: string }> {
    const project = await this.requireProject(projectName);
    const thread = await this.chatRepo.createThread({
      projectId: project.id,
      ownerId,
      ...(title !== undefined ? { title } : {}),
    });
    return { id: thread.id };
  }

  /** List the caller's threads for a project (or all, when ownerId omitted). */
  async listProjectThreads(projectName: string, ownerId?: string): Promise<Array<{ id: string; title: string; lastTurnAt: Date; createdAt: Date }>> {
    const project = await this.requireProject(projectName);
    const rows = await this.chatRepo.listThreadsByProject(project.id, ownerId);
    return rows.map((r) => ({ id: r.id, title: r.title, lastTurnAt: r.lastTurnAt, createdAt: r.createdAt }));
  }

  /**
   * Delete a thread. Allowed when the caller owns it, or holds admin override
   * (`canDeleteOthers`, wired to `delete:projects:<name>` at the route). 404
   * (not 403) on a foreign thread the caller can't override — no id-enumeration.
   */
  async deleteThread(threadId: string, ownerId: string, canDeleteOthers = false): Promise<void> {
    const thread = await this.chatRepo.findThread(threadId);
    if (thread === null) throw new NotFoundError(`Thread not found: ${threadId}`);
    if (thread.ownerId !== ownerId && !canDeleteOthers) {
      throw new NotFoundError(`Thread not found: ${threadId}`);
    }
    await this.chatRepo.deleteThread(threadId);
  }

  private async requireProject(projectName: string): Promise<{ id: string; name: string; prompt: string; llmProvider: string | null; llmModel: string | null }> {
    if (this.projects === undefined) {
      throw new Error('project chat is not wired into ChatService (no project repository)');
    }
    const project = await this.projects.findByName(projectName);
    if (project === null) throw new NotFoundError(`Project not found: ${projectName}`);
    return project;
  }

  private async prepareProjectContext(args: ChatProjectRequestArgs): Promise<PreparedChatContext> {
    const project = await this.requireProject(args.projectName);
    if (project.llmProvider === null || project.llmProvider === '') {
      throw new NotFoundError(
        `Project '${project.name}' has no llmProvider set. Set one with 'mcpctl edit project ${project.name}' (llmProvider = an Llm name).`,
      );
    }
    const pinned = await this.llms.getByName(project.llmProvider);
    // project.llmModel overrides the pinned Llm's own model for this chat.
    const poolCandidates = await this.resolveCandidatesWithFallbacks(pinned, project.llmModel ?? undefined);
    if (poolCandidates.length === 0) {
      throw new NotFoundError(
        `No active Llm in pool '${effectivePoolName(pinned)}' (project '${project.name}' → ${pinned.name})`,
      );
    }

    const threadId = await this.resolveProjectThreadId(args, project.id);
    const projectId = project.id;

    // System block: the project's own prompt text first, then its Prompts by
    // priority desc (reusing the agent-path filter). No agent/personality.
    const projectPrompts = (await this.promptRepo.findAll(projectId))
      .filter((p) => p.projectId === projectId)
      .sort((a, b) => b.priority - a.priority);

    const mergedParams: AgentChatParams = { ...(args.params ?? {}) };
    const baseSystem = mergedParams.systemOverride ?? project.prompt;
    const systemBlock = [
      baseSystem,
      ...projectPrompts.map((p) => p.content),
      mergedParams.systemAppend ?? '',
    ]
      .filter((s) => s.length > 0)
      .join('\n\n');

    const history = args.messagesOverride !== undefined
      ? [...args.messagesOverride]
      : await this.loadHistory(threadId);

    let startingTurnIndex = await this.chatRepo.nextTurnIndex(threadId);
    if (args.userMessage !== undefined && args.messagesOverride === undefined) {
      const userTurn = await this.chatRepo.appendMessage({ threadId, role: 'user', content: args.userMessage });
      startingTurnIndex = userTurn.turnIndex;
      history.push({ role: 'user', content: args.userMessage });
    }

    const baseTools = await this.tools.listTools(projectId);
    // Gated secret-read tool: only offered when the caller asked (--allow-secrets)
    // AND holds view:secrets. Never exposed by default.
    const allowSecrets = await this.resolveAllowSecrets(args);
    const toolList = allowSecrets ? [...baseTools, secretReadTool()] : baseTools;
    const allowed = mergedParams.tools_allowlist;
    const filteredTools = allowed === undefined ? toolList : toolList.filter((t) => allowed.includes(t.name));

    return {
      threadId,
      history,
      systemBlock,
      poolCandidates,
      primaryLlm: pinned.name,
      mergedParams,
      toolList: filteredTools,
      projectId,
      startingTurnIndex,
      maxIterations: MAX_ITERATIONS,
      allowSecrets,
    };
  }

  /** Gate the get_secret tool: requires the flag AND the caller's view:secrets. */
  private async resolveAllowSecrets(args: ChatProjectRequestArgs): Promise<boolean> {
    if (args.allowSecrets !== true) return false;
    if (this.secrets === undefined) return false;
    if (this.rbac === undefined) return true; // no RBAC wired (tests) → trust the flag
    return this.rbac.canAccess(args.ownerId, 'view', 'secrets');
  }

  private async resolveProjectThreadId(args: ChatProjectRequestArgs, projectId: string): Promise<string> {
    if (args.threadId !== undefined) {
      const existing = await this.chatRepo.findThread(args.threadId);
      if (existing === null) throw new NotFoundError(`Thread not found: ${args.threadId}`);
      // Owner check on resume (mirrors listMessages): a foreign thread id 404s.
      if (existing.ownerId !== args.ownerId) throw new NotFoundError(`Thread not found: ${args.threadId}`);
      return existing.id;
    }
    const created = await this.chatRepo.createThread({ projectId, ownerId: args.ownerId });
    return created.id;
  }

  /**
   * v4: resolve the load-balanced pool for an agent's pinned Llm. The
   * pool is "all Llms whose effective key (poolName ?? name) matches the
   * pinned row's effective key", filtered to viable backends:
   *   - public:  always included (no per-row health probe in v4)
   *   - virtual: included when status is 'active' or 'hibernating'
   *              (hibernating members get woken by VirtualLlmService when
   *              dispatched). 'inactive' = publisher offline, exclude.
   *
   * Order is randomised so load spreads across members. The pinned row
   * doesn't get priority — if it happens to be down, a sibling takes
   * the call without the caller noticing.
   */
  private async resolvePoolCandidates(
    pinned: { name: string; poolName: string | null },
    modelOverride?: string,
  ): Promise<PoolCandidate[]> {
    const poolKey = effectivePoolName(pinned);
    const rows = await this.llms.findByPoolName(poolKey);
    const viable = rows.filter((r) => r.status !== 'inactive');
    // Fisher-Yates is overkill for typical pool sizes (1-5); a
    // sort-by-random-key is adequate and side-effect-free.
    const shuffled = [...viable].sort(() => Math.random() - 0.5);
    return Promise.all(shuffled.map(async (r) => {
      const apiKey = await this.llms.resolveApiKey(r.name).catch(() => '');
      return {
        llmName: r.name,
        llmType: r.type,
        llmKind: r.kind as 'public' | 'virtual',
        // A project can pin all its pool members to one served model via
        // project.llmModel; otherwise each row uses its own configured model.
        modelOverride: modelOverride ?? r.model,
        url: r.url,
        apiKey,
        extraConfig: r.extraConfig as Record<string, unknown>,
      };
    }));
  }

  /**
   * Build the ordered dispatch list: the primary Llm's pool, then each fallback
   * Llm's pool (declared on the primary via `extraConfig.fallbacks: string[]`).
   * The dispatch loop tries them in order and fails over on error/4xx/no-completion.
   * A missing/misconfigured fallback is skipped, never fatal.
   */
  private async resolveCandidatesWithFallbacks(
    pinned: { name: string; poolName: string | null; extraConfig?: unknown },
    modelOverride?: string,
  ): Promise<PoolCandidate[]> {
    const candidates = await this.resolvePoolCandidates(pinned, modelOverride);
    for (const name of extractFallbackNames(pinned.extraConfig)) {
      if (name === pinned.name) continue; // no self-reference loops
      try {
        const fb = await this.llms.getByName(name);
        // A fallback uses its OWN configured model — not the primary's override.
        candidates.push(...(await this.resolvePoolCandidates(fb)));
      } catch {
        // Fallback missing/inactive — skip it, keep the rest of the chain.
      }
    }
    return candidates;
  }

  /**
   * Resolves a personality (request override → agent default) and returns
   * its bound prompt contents in `PersonalityPrompt.priority` desc order.
   * Returns `[]` when no personality is selected, when the personality
   * repository is not wired (legacy callers), or when the named personality
   * doesn't exist on this agent. The "doesn't exist" case throws — typos in
   * a CLI flag should fail loudly, not silently fall back to no overlay.
   */
  private async resolvePersonalityPrompts(
    args: ChatRequestArgs,
    agent: Awaited<ReturnType<AgentService['getByName']>>,
  ): Promise<string[]> {
    if (this.personalities === undefined) return [];

    let personalityId: string | null = null;
    if (args.personalityName !== undefined && args.personalityName !== '') {
      const named = await this.personalities.findByNameAndAgent(args.personalityName, agent.id);
      if (named === null) {
        throw new NotFoundError(
          `Personality not found on agent ${agent.name}: ${args.personalityName}`,
        );
      }
      personalityId = named.id;
    } else if (agent.defaultPersonality !== null) {
      personalityId = agent.defaultPersonality.id;
    }
    if (personalityId === null) return [];

    const bindings = await this.personalities.listPrompts(personalityId);
    return [...bindings]
      .sort((a, b) => b.priority - a.priority)
      .map((b) => b.prompt.content);
  }

  private async resolveThreadId(args: ChatRequestArgs, agentId: string): Promise<string> {
    if (args.threadId !== undefined) {
      const existing = await this.chatRepo.findThread(args.threadId);
      if (existing === null) throw new NotFoundError(`Thread not found: ${args.threadId}`);
      return existing.id;
    }
    const created = await this.chatRepo.createThread({ agentId, ownerId: args.ownerId });
    return created.id;
  }

  private async loadHistory(threadId: string): Promise<OpenAiMessage[]> {
    const rows = await this.chatRepo.listMessages(threadId);
    return rows
      .filter((r) => r.status !== 'error')
      .map<OpenAiMessage>((r) => {
        const msg: OpenAiMessage = { role: r.role as ChatRole, content: r.content };
        if (r.toolCallId !== null) msg.tool_call_id = r.toolCallId;
        if (r.toolCalls !== null && Array.isArray(r.toolCalls)) {
          const calls = r.toolCalls as Array<{ id: string; name: string; arguments: unknown }>;
          msg.tool_calls = calls.map((c) => ({
            id: c.id,
            type: 'function' as const,
            function: { name: c.name, arguments: typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments) },
          }));
        }
        return msg;
      });
  }

  private buildBody(ctx: {
    history: OpenAiMessage[];
    systemBlock: string;
    modelOverride: string;
    mergedParams: AgentChatParams;
    toolList: ChatTool[];
  }): OpenAiChatRequest {
    const messages: OpenAiMessage[] = [];
    if (ctx.systemBlock.length > 0) {
      messages.push({ role: 'system', content: ctx.systemBlock });
    }
    messages.push(...ctx.history);
    const body: OpenAiChatRequest = {
      model: ctx.modelOverride,
      messages,
    };
    const p = ctx.mergedParams;
    if (p.temperature !== undefined) body.temperature = p.temperature;
    if (p.top_p !== undefined) body.top_p = p.top_p;
    if (p.top_k !== undefined) (body as Record<string, unknown>)['top_k'] = p.top_k;
    if (p.max_tokens !== undefined) body.max_tokens = p.max_tokens;
    if (p.stop !== undefined) body.stop = p.stop;
    if (p.presence_penalty !== undefined) (body as Record<string, unknown>)['presence_penalty'] = p.presence_penalty;
    if (p.frequency_penalty !== undefined) (body as Record<string, unknown>)['frequency_penalty'] = p.frequency_penalty;
    if (p.seed !== undefined) (body as Record<string, unknown>)['seed'] = p.seed;
    if (p.response_format !== undefined) (body as Record<string, unknown>)['response_format'] = p.response_format;
    if (p.tool_choice !== undefined) body.tool_choice = p.tool_choice;
    if (ctx.toolList.length > 0) {
      body.tools = ctx.toolList.map((t) => ({
        type: 'function' as const,
        function: { name: t.name, description: t.description, parameters: t.parameters },
      }));
    }
    if (p.extra !== undefined) {
      for (const [k, v] of Object.entries(p.extra)) {
        (body as Record<string, unknown>)[k] = v;
      }
    }
    return body;
  }

  private async dispatchTool(toolWireName: string, argsJson: string, projectId: string | null, allowSecrets: boolean): Promise<unknown> {
    if (projectId === null) {
      throw new Error('Tool calls require an agent attached to a Project');
    }
    // Gated virtual tool: reading a secret value. Only honored when the chat
    // was started with secret access AND the resolver is wired — never routes
    // to an MCP server. A hallucinated call without the gate fails loudly.
    if (toolWireName === `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`) {
      if (!allowSecrets || this.secrets === undefined) {
        throw new Error('secret access is not enabled for this chat');
      }
      const parsed = safeParseJson(argsJson) as { name?: unknown; key?: unknown };
      if (typeof parsed.name !== 'string' || typeof parsed.key !== 'string') {
        throw new Error('get_secret requires string arguments { name, key }');
      }
      return this.secrets.resolve(parsed.name, parsed.key);
    }
    const sep = toolWireName.indexOf(TOOL_NAME_SEPARATOR);
    if (sep === -1) {
      throw new Error(`Tool name '${toolWireName}' missing '${TOOL_NAME_SEPARATOR}' separator`);
    }
    const serverName = toolWireName.slice(0, sep);
    const toolName = toolWireName.slice(sep + TOOL_NAME_SEPARATOR.length);
    const parsed = safeParseJson(argsJson) as Record<string, unknown>;
    return this.tools.callTool({ projectId, serverName, toolName, args: parsed });
  }
}

interface ExtractedChoice {
  content: string | null;
  /**
   * Reasoning text emitted by thinking models (qwen3-thinking,
   * deepseek-reasoner, OpenAI o1 family). Different providers spell the
   * field differently; the parser accepts every shape the streaming
   * counterpart already accepts (see `parseStreamingChunk`). When `content`
   * is null/empty, callers fall back to this so thinking models that
   * exhaust their token budget on reasoning still produce a usable answer.
   */
  reasoning?: string;
  /** OpenAI's stop reason — `'stop' | 'length' | 'tool_calls' | 'content_filter' | ...`. */
  finishReason?: string | null;
  tool_calls?: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }>;
}

function extractChoice(body: unknown): ExtractedChoice | null {
  if (typeof body !== 'object' || body === null) return null;
  const choices = (body as { choices?: unknown }).choices;
  if (!Array.isArray(choices) || choices.length === 0) return null;
  const first = choices[0] as {
    message?: {
      content?: unknown;
      reasoning_content?: unknown;
      reasoning?: unknown;
      provider_specific_fields?: { reasoning_content?: unknown; reasoning?: unknown };
      tool_calls?: unknown;
    };
    finish_reason?: unknown;
  } | undefined;
  if (first?.message === undefined) return null;
  const content = typeof first.message.content === 'string' ? first.message.content : null;
  const m = first.message;
  const reasoning =
    (typeof m.reasoning_content === 'string' && m.reasoning_content.length > 0 ? m.reasoning_content : undefined)
    ?? (typeof m.reasoning === 'string' && m.reasoning.length > 0 ? m.reasoning : undefined)
    ?? (typeof m.provider_specific_fields?.reasoning_content === 'string' && m.provider_specific_fields.reasoning_content.length > 0 ? m.provider_specific_fields.reasoning_content : undefined)
    ?? (typeof m.provider_specific_fields?.reasoning === 'string' && m.provider_specific_fields.reasoning.length > 0 ? m.provider_specific_fields.reasoning : undefined);
  const finishReason = typeof first.finish_reason === 'string' ? first.finish_reason : null;
  const toolCalls = first.message.tool_calls;
  const out: ExtractedChoice = { content, finishReason };
  if (reasoning !== undefined) out.reasoning = reasoning;
  if (Array.isArray(toolCalls)) {
    out.tool_calls = toolCalls as NonNullable<ExtractedChoice['tool_calls']>;
  }
  return out;
}

/**
 * Pick what text to surface (and persist) as the assistant's reply.
 * Thinking models sometimes emit only `reasoning_content` and leave
 * `content` null — typically when `max_tokens` is too small for the
 * thinking budget, but also when the provider configuration just doesn't
 * separate the two. In that case the reasoning IS the answer for this
 * request, and the caller should see it. A `length` finish_reason marker
 * makes truncation visible so users can fix their max_tokens config.
 */
function pickAssistantText(choice: ExtractedChoice): string {
  if (choice.content !== null && choice.content.length > 0) return choice.content;
  if (choice.reasoning !== undefined && choice.reasoning.length > 0) {
    const truncated = choice.finishReason === 'length' ? '\n\n[response truncated by max_tokens]' : '';
    return `${choice.reasoning}${truncated}`;
  }
  return '';
}

function safeParseJson(s: string): unknown {
  if (s === '') return {};
  try {
    return JSON.parse(s);
  } catch {
    return {};
  }
}

/** Read an ordered fallback-Llm list from a primary Llm's `extraConfig.fallbacks`. */
function extractFallbackNames(extraConfig: unknown): string[] {
  if (extraConfig === null || typeof extraConfig !== 'object') return [];
  const raw = (extraConfig as { fallbacks?: unknown }).fallbacks;
  if (!Array.isArray(raw)) return [];
  return raw.filter((x): x is string => typeof x === 'string' && x.length > 0);
}

/** The gated virtual secret-read tool offered to project chat with --allow-secrets. */
function secretReadTool(): ChatTool {
  return {
    name: `${SECRET_TOOL_SERVER}${TOOL_NAME_SEPARATOR}${SECRET_TOOL_NAME}`,
    description:
      'Read the value of one key inside a named mcpctl Secret. Use ONLY when you '
      + 'genuinely need a credential or config value to complete the task. Returns '
      + 'the raw secret value as text.',
    parameters: {
      type: 'object',
      properties: {
        name: { type: 'string', description: 'The Secret resource name (see `mcpctl get secrets`).' },
        key: { type: 'string', description: "The key inside that secret's data map." },
      },
      required: ['name', 'key'],
    },
  };
}

interface ParsedStreamEvent {
  contentDelta?: string;
  /**
   * Reasoning text emitted by thinking models (qwen3-thinking,
   * deepseek-reasoner, OpenAI o1 family). Different providers spell the
   * field differently — we accept both `reasoning_content` (qwen, deepseek)
   * and `reasoning` (some o1 variants) and the older `provider_specific_fields.reasoning`
   * shape that LiteLLM passes through from vLLM.
   */
  reasoningDelta?: string;
  toolCallDeltas?: Array<{ index: number; id?: string; name?: string; argumentsDelta?: string }>;
  finishReason?: string | null;
}

function parseStreamingChunk(data: string): ParsedStreamEvent | null {
  if (data === '' || data === '[DONE]') return null;
  let json: unknown;
  try {
    json = JSON.parse(data);
  } catch {
    return null;
  }
  if (typeof json !== 'object' || json === null) return null;
  const choices = (json as { choices?: unknown }).choices;
  if (!Array.isArray(choices) || choices.length === 0) return null;
  const c = choices[0] as {
    delta?: {
      content?: unknown;
      reasoning_content?: unknown;
      reasoning?: unknown;
      tool_calls?: unknown;
      provider_specific_fields?: { reasoning_content?: unknown; reasoning?: unknown };
    };
    finish_reason?: unknown;
  };
  const evt: ParsedStreamEvent = {};
  const delta = c.delta;
  if (delta !== undefined) {
    if (typeof delta.content === 'string' && delta.content.length > 0) {
      evt.contentDelta = delta.content;
    }
    // Try the standard fields first, then the LiteLLM passthrough shape.
    const reasoning =
      (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0 ? delta.reasoning_content : undefined)
      ?? (typeof delta.reasoning === 'string' && delta.reasoning.length > 0 ? delta.reasoning : undefined)
      ?? (typeof delta.provider_specific_fields?.reasoning_content === 'string' && delta.provider_specific_fields.reasoning_content.length > 0 ? delta.provider_specific_fields.reasoning_content : undefined)
      ?? (typeof delta.provider_specific_fields?.reasoning === 'string' && delta.provider_specific_fields.reasoning.length > 0 ? delta.provider_specific_fields.reasoning : undefined);
    if (reasoning !== undefined) {
      evt.reasoningDelta = reasoning;
    }
    if (Array.isArray(delta.tool_calls)) {
      evt.toolCallDeltas = (delta.tool_calls as Array<{
        index: number;
        id?: string;
        function?: { name?: string; arguments?: string };
      }>).map((t) => {
        const td: { index: number; id?: string; name?: string; argumentsDelta?: string } = { index: t.index };
        if (t.id !== undefined) td.id = t.id;
        if (t.function?.name !== undefined) td.name = t.function.name;
        if (t.function?.arguments !== undefined) td.argumentsDelta = t.function.arguments;
        return td;
      });
    }
  }
  if (c.finish_reason !== undefined) {
    evt.finishReason = (c.finish_reason as string | null);
  }
  return evt;
}
