import {
  getDashscopeApiKey,
  getTokendanceApiKey,
  getTokendanceBaseUrl,
  getZenmuxApiKey,
  isCustomKeyEnabled,
} from "@/lib/api-keys";
import { ALL_MODELS, AVAILABLE_MODELS, PROJECT_MODELS, type ModelRef } from "@/types/game";
import { gameStatsTracker } from "@/hooks/useGameStats";
import { gameSessionTracker } from "@/lib/game-session-tracker";
import { getAuthHeaders } from "@/lib/auth-headers";
import { parseLLMJson } from "./llm-json";

export type LLMContentPart =
  | { type: "text"; text: string; cache_control?: { type: "ephemeral"; ttl?: "1h" } }
  | { type: "image_url"; image_url: { url: string; detail?: string } }
  | { type: "input_audio"; input_audio: { data: string; format: "mp3" | "wav" } };

export type ApiKeySource = "user" | "project";

export interface LLMMessage {
  role: "system" | "user" | "assistant";
  content: string | LLMContentPart[];
  reasoning_details?: unknown;
}

type Provider = "zenmux" | "dashscope" | "tokendance";

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}


function getProviderForModel(model: string): Provider {
   const modelRef =
     ALL_MODELS.find((ref) => ref.model === model) ??
     PROJECT_MODELS.find((ref) => ref.model === model);
   return modelRef?.provider ?? "zenmux";
 }

// When using built-in keys (custom disabled), only project-key models are allowed.
// Game state may contain modelRef from a custom-key game; map it back to a built-in
// model to avoid requiring a user-supplied key after the toggle is turned off.
function resolveModelForBuiltin(model: string): string {
  if (PROJECT_MODELS.some((r) => r.model === model)) return model;
  const m =
    AVAILABLE_MODELS.find((r) => r.provider === "zenmux") ?? AVAILABLE_MODELS[0];
  return m?.model ?? model;
}

export function resolveApiKeySource(model: string): ApiKeySource {
   const customEnabled = isCustomKeyEnabled();
   if (!customEnabled) return "project";

   const provider = getProviderForModel(model);
   if (provider === "dashscope") {
     return getDashscopeApiKey() ? "user" : "project";
   }
   if (provider === "tokendance") {
     return getTokendanceApiKey() && getTokendanceBaseUrl() ? "user" : "project";
   }
   return getZenmuxApiKey() ? "user" : "project";
 }

function buildCustomKeyHeaders(customEnabled: boolean): Record<string, string> {
  if (!customEnabled) return {};

  const zenmuxApiKey = getZenmuxApiKey();
  const dashscopeApiKey = getDashscopeApiKey();
  const tokendanceApiKey = getTokendanceApiKey();
  const tokendanceBaseUrl = getTokendanceBaseUrl();
  return {
    ...(zenmuxApiKey ? { "X-Zenmux-Api-Key": zenmuxApiKey } : {}),
    ...(dashscopeApiKey ? { "X-Dashscope-Api-Key": dashscopeApiKey } : {}),
    ...(tokendanceApiKey ? { "X-Tokendance-Api-Key": tokendanceApiKey } : {}),
    ...(tokendanceBaseUrl ? { "X-Tokendance-Base-Url": tokendanceBaseUrl } : {}),
  };
}

export interface ChatCompletionResponse {
  id: string;
  choices: {
    message: {
      role: "assistant";
      content: string;
      reasoning_details?: unknown;
    };
    finish_reason: string;
  }[];
  usage?: {
    prompt_tokens: number;
    completion_tokens: number;
    total_tokens: number;
    prompt_cache_hit_tokens?: number;
    prompt_cache_miss_tokens?: number;
    prompt_tokens_details?: {
      cached_tokens?: number;
    } | null;
  };
}

export interface PromptCacheUsage {
  promptCacheInputTokens: number;
  promptCacheHitTokens: number;
  promptCacheMissTokens: number;
  promptCacheHitRatio: number;
  source: "prompt_cache_hit_tokens" | "prompt_tokens_details.cached_tokens" | "none";
}

export function extractPromptCacheUsage(usage: ChatCompletionResponse["usage"] | undefined): PromptCacheUsage {
  const promptTokens =
    typeof usage?.prompt_tokens === "number" && Number.isFinite(usage.prompt_tokens)
      ? usage.prompt_tokens
      : 0;

  if (typeof usage?.prompt_cache_hit_tokens === "number") {
    const hit = Math.max(0, usage.prompt_cache_hit_tokens);
    const miss =
      typeof usage.prompt_cache_miss_tokens === "number"
        ? Math.max(0, usage.prompt_cache_miss_tokens)
        : Math.max(0, promptTokens - hit);
    const input = hit + miss || promptTokens;
    return {
      promptCacheInputTokens: input,
      promptCacheHitTokens: hit,
      promptCacheMissTokens: miss,
      promptCacheHitRatio: input > 0 ? hit / input : 0,
      source: "prompt_cache_hit_tokens",
    };
  }

  const cachedTokens = usage?.prompt_tokens_details?.cached_tokens;
  if (typeof cachedTokens === "number" && Number.isFinite(cachedTokens)) {
    const hit = Math.max(0, cachedTokens);
    const miss = Math.max(0, promptTokens - hit);
    return {
      promptCacheInputTokens: promptTokens,
      promptCacheHitTokens: hit,
      promptCacheMissTokens: miss,
      promptCacheHitRatio: promptTokens > 0 ? hit / promptTokens : 0,
      source: "prompt_tokens_details.cached_tokens",
    };
  }

  return {
    promptCacheInputTokens: promptTokens,
    promptCacheHitTokens: 0,
    promptCacheMissTokens: promptTokens,
    promptCacheHitRatio: 0,
    source: "none",
  };
}

export type ResponseFormat =
  | { type: "text" }
  | { type: "json_object" }
  | {
      type: "json_schema";
      strict?: boolean;
      json_schema: {
        name: string;
        description?: string;
        schema: unknown;
        // Note: 'strict' is not supported by ZenMux, use json_object for simple cases
      };
    };

// ZenMux reasoning: enabled, effort (minimal|low|medium|high), max_tokens (optional). No exclude.
export interface ReasoningOptions {
  enabled: boolean;
  effort?: "minimal" | "low" | "medium" | "high";
  max_tokens?: number;
}

export interface GenerateOptions {
  model: string;
  provider?: Provider;
  messages: LLMMessage[];
  temperature?: number;
  max_tokens?: number;
  reasoning?: ReasoningOptions;
  reasoning_effort?: "minimal" | "low" | "medium" | "high";
  response_format?: ResponseFormat;
}

/** Merge modelRef overrides (temperature, reasoning) into options; modelRef values override call-time when present. */
export function mergeOptionsFromModelRef<T extends GenerateOptions>(
  modelRef: ModelRef | undefined,
  options: T
): T {
  if (!modelRef) return options;
  const out = { ...options } as T;
  (out as GenerateOptions).provider = modelRef.provider;
  if (modelRef.temperature !== undefined) (out as GenerateOptions).temperature = modelRef.temperature;
  if (modelRef.reasoning !== undefined) (out as GenerateOptions).reasoning = modelRef.reasoning;
  return out;
}

export type BatchCompletionResult =
  | { ok: true; content: string; reasoning_details?: unknown; raw: ChatCompletionResponse }
  | { ok: false; error: string; status?: number };

const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);

function parseRetryAfterMs(response: Response): number | null {
  const raw = response.headers.get("retry-after");
  if (!raw) return null;
  const sec = Number(raw);
  if (Number.isFinite(sec) && sec > 0) return Math.round(sec * 1000);

  const dateMs = Date.parse(raw);
  if (!Number.isFinite(dateMs)) return null;
  const diff = dateMs - Date.now();
  return diff > 0 ? diff : null;
}

const QUOTA_EXHAUSTED_MARKER = "[QUOTA_EXHAUSTED]";

function isQuotaExhaustedError(status: number, errorText: string): boolean {
  if (status === 402) return true;
  const lower = errorText.toLowerCase();
  return (
    lower.includes("insufficient") ||
    lower.includes("quota") ||
    lower.includes("balance") ||
    lower.includes("余额") ||
    lower.includes("欠费") ||
    lower.includes("arrearage") ||
    (status === 401 && lower.includes("已启用自定义 key"))
  );
}

function formatApiError(status: number, errorText: string): string {
  let msg = `API error: ${status}`;
  try {
    const errorJson: unknown = JSON.parse(errorText);
    if (isRecord(errorJson)) {
      if (typeof errorJson.error === "string" && errorJson.error.trim()) {
        msg = errorJson.error.trim();
      }

      const details = errorJson.details;
      if (isRecord(details)) {
        const nestedError = details.error;
        if (isRecord(nestedError) && typeof nestedError.message === "string" && nestedError.message.trim()) {
          msg = `${msg} - ${nestedError.message.trim()}`;
        }
      }
    }
  } catch {
    const trimmed = (errorText || "").trim();
    msg = trimmed ? `${msg} - ${trimmed.slice(0, 600)}` : msg;
  }

  if (isQuotaExhaustedError(status, errorText)) {
    return `${QUOTA_EXHAUSTED_MARKER} ${msg}`;
  }
  return msg;
}

export function isQuotaExhaustedMessage(message: string): boolean {
  return message.includes(QUOTA_EXHAUSTED_MARKER);
}

async function sleep(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

async function fetchWithRetry(
  input: RequestInfo | URL,
  init: RequestInit,
  maxAttempts: number
): Promise<Response> {
  let lastResponse: Response | null = null;
  let lastError: unknown = null;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const response = await fetch(input, init);
      lastResponse = response;

      if (response.ok) return response;

      if (!RETRYABLE_STATUS.has(response.status) || attempt === maxAttempts) {
        return response;
      }

      const retryAfterMs = parseRetryAfterMs(response);
      const base = response.status === 429 ? 1000 : 400;
      const jitter = Math.floor(Math.random() * 200);
      const backoffMs =
        (retryAfterMs !== null ? Math.min(15000, Math.max(0, retryAfterMs)) : base * 2 ** (attempt - 1)) +
        jitter;
      await sleep(backoffMs);
    } catch (err) {
      lastError = err;
      if (attempt === maxAttempts) break;
      const base = 400;
      const jitter = Math.floor(Math.random() * 200);
      const backoffMs = base * 2 ** (attempt - 1) + jitter;
      await sleep(backoffMs);
    }
  }

  if (lastResponse) return lastResponse;
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
}

/** 剥离 MiniMax 等模型在 content 中嵌入的 <think>...</think> 思考块 */
const REASONING_TAG_NAMES = ["think", "thinking", "analysis", "reasoning", "thought"];
const REASONING_TAG_PATTERN = REASONING_TAG_NAMES.join("|");

/** Remove model reasoning artifacts that may be embedded in assistant content. */
export function stripReasoningArtifacts(text: string): string {
  return stripReasoningArtifactsPreserveWhitespace(text).trim();
}

function stripReasoningArtifactsPreserveWhitespace(text: string): string {
  if (!text) return text;

  return text
    .replace(
      new RegExp(
        `<\\s*(${REASONING_TAG_PATTERN})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>\\s*`,
        "gi"
      ),
      ""
    )
    .replace(new RegExp(`<\\s*\\/?\\s*(${REASONING_TAG_PATTERN})\\b[^>]*>`, "gi"), "");
}

function findReasoningEnd(text: string): { end: number } | null {
  const match = new RegExp(`<\\s*\\/\\s*(${REASONING_TAG_PATTERN})\\s*>`, "i").exec(text);
  return match ? { end: match.index + match[0].length } : null;
}

function couldBeReasoningStart(text: string): boolean {
  if (!text.startsWith("<")) return false;
  const lowered = text.toLowerCase();
  return REASONING_TAG_NAMES.some((name) => `<${name}`.startsWith(lowered) || lowered.startsWith(`<${name}`));
}

export function stripMarkdownCodeFences(text: string): string {
  let t = text.trim();

  if (t.startsWith("```")) {
    t = t.replace(/^```[a-zA-Z0-9_-]*\s*/m, "");
    t = t.replace(/\s*```\s*$/m, "");
  }

  return t.trim();
}

function stripJsonPrefix(text: string): string {
  const t = text.trimStart();
  if (/^json\s*[\[{]/i.test(t)) {
    return t.replace(/^json\s*/i, "");
  }
  return text;
}

function extractFirstJsonBlock(text: string): string | null {
  const startObj = text.indexOf("{");
  const startArr = text.indexOf("[");
  const start =
    startObj === -1 ? startArr : startArr === -1 ? startObj : Math.min(startObj, startArr);
  if (start === -1) return null;

  const opening = text[start];
  const expectedClosing = opening === "{" ? "}" : "]";

  let i = start;
  let depth = 0;
  let inString = false;
  let escaping = false;
  for (; i < text.length; i += 1) {
    const ch = text[i];

    if (inString) {
      if (escaping) {
        escaping = false;
        continue;
      }
      if (ch === "\\") {
        escaping = true;
        continue;
      }
      if (ch === '"') {
        inString = false;
      }
      continue;
    }

    if (ch === '"') {
      inString = true;
      continue;
    }
    if (ch === opening) {
      depth += 1;
      continue;
    }
    if (ch === expectedClosing) {
      depth -= 1;
      if (depth === 0) {
        return text.slice(start, i + 1);
      }
      continue;
    }

    if (opening === "{" && ch === "[") {
      depth += 1;
      continue;
    }
    if (opening === "{" && ch === "]") {
      depth = Math.max(0, depth - 1);
      if (depth === 0) {
        return text.slice(start, i + 1);
      }
      continue;
    }
    if (opening === "[" && ch === "{") {
      depth += 1;
      continue;
    }
    if (opening === "[" && ch === "}") {
      depth = Math.max(0, depth - 1);
      if (depth === 0) {
        return text.slice(start, i + 1);
      }
      continue;
    }
  }

  return null;
}

function normalizeJsonText(text: string): string {
  return text
    .replace(/[\u201C\u201D]/g, '"')
    .replace(/[\u2018\u2019]/g, "'")
    .replace(/,\s*([}\]])/g, "$1")
    .trim();
}

function escapeDanglingQuotesInStrings(text: string): string {
  let out = "";
  let inString = false;
  let escaping = false;

  const nextNonWs = (idx: number): string | null => {
    for (let j = idx; j < text.length; j += 1) {
      const c = text[j];
      if (!/\s/.test(c)) return c;
    }
    return null;
  };

  for (let i = 0; i < text.length; i += 1) {
    const ch = text[i];
    if (!inString) {
      if (ch === '"') inString = true;
      out += ch;
      continue;
    }

    if (escaping) {
      escaping = false;
      out += ch;
      continue;
    }

    if (ch === "\\") {
      escaping = true;
      out += ch;
      continue;
    }

    if (ch === '"') {
      const n = nextNonWs(i + 1);
      const isTerminator = n === null || n === "," || n === "}" || n === "]" || n === ":";
      if (isTerminator) {
        inString = false;
        out += ch;
        continue;
      }
      out += "\\\"";
      continue;
    }

    out += ch;
  }

  return out;
}

function parseJsonTolerant<T>(raw: string): T {
  const trimmed = stripJsonPrefix(stripMarkdownCodeFences(raw));
  const repairedJson = parseLLMJson<T>(trimmed);
  if (repairedJson !== null) return repairedJson;

  const direct = normalizeJsonText(trimmed);
  try {
    return JSON.parse(direct) as T;
  } catch {
    // continue
  }

  const extracted = extractFirstJsonBlock(direct) ?? extractFirstJsonBlock(trimmed);
  if (!extracted) {
    throw new Error(`Failed to parse JSON response: ${raw}`);
  }

  const normalized = normalizeJsonText(extracted);
  try {
    return JSON.parse(normalized) as T;
  } catch {
    // continue
  }

  const repaired = escapeDanglingQuotesInStrings(normalized);
  try {
    return JSON.parse(repaired) as T;
  } catch {
    throw new Error(`Failed to parse JSON response: ${raw}`);
  }
}

function attachGameSessionHeader(headers: Record<string, string>) {
  const sessionId = gameSessionTracker.getSessionId();
  if (sessionId) {
    headers["X-Game-Session-Id"] = sessionId;
  }
}

export async function generateCompletion(
  options: GenerateOptions
): Promise<{ content: string; reasoning_details?: unknown; raw: ChatCompletionResponse }> {
  const maxTokens =
    typeof options.max_tokens === "number" && Number.isFinite(options.max_tokens)
      ? Math.max(16, Math.floor(options.max_tokens))
      : undefined;

  const customEnabled = isCustomKeyEnabled();
  const modelToUse = customEnabled
    ? options.model
    : resolveModelForBuiltin(options.model);
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...buildCustomKeyHeaders(customEnabled),
  };

  Object.assign(headers, await getAuthHeaders());
  attachGameSessionHeader(headers);

  console.log("[LLM] generateCompletion:", {
    customEnabled,
    hasZenmuxKey: !!headers["X-Zenmux-Api-Key"],
    hasDashscopeKey: !!headers["X-Dashscope-Api-Key"],
    hasTokendanceKey: !!headers["X-Tokendance-Api-Key"],
    model: modelToUse,
  });

  const response = await fetchWithRetry(
    "/api/chat",
    {
      method: "POST",
      headers: {
        ...headers,
      },
      body: JSON.stringify({
        model: modelToUse,
        ...(options.provider ? { provider: options.provider } : {}),
        messages: options.messages,
        temperature: options.temperature ?? 0.7,
        max_tokens: maxTokens,
        ...(options.reasoning ? { reasoning: options.reasoning } : {}),
        ...(options.reasoning_effort ? { reasoning_effort: options.reasoning_effort } : {}),
        ...(options.response_format ? { response_format: options.response_format } : {}),
      }),
    },
    4
  );

  if (!response.ok) {
    const errorText = await response.text().catch(() => "");
    throw new Error(formatApiError(response.status, errorText));
  }

  const result: ChatCompletionResponse = await response.json();
  const choice = result.choices?.[0];
  const assistantMessage = choice?.message;

  if (!assistantMessage) {
    throw new Error(
      `No response from model. Raw response: ${JSON.stringify(result).slice(0, 500)}`
    );
  }

  // Warn if output was truncated due to max_tokens
  if (choice.finish_reason === "length") {
    console.warn(
      `[LLM] Output truncated (finish_reason=length). Consider increasing max_tokens.`
    );
  }

  // 统计 AI 调用
  const inputChars = options.messages.reduce((sum, m) => {
    if (typeof m.content === "string") return sum + m.content.length;
    if (Array.isArray(m.content)) {
      return sum + m.content.reduce((s, p) => s + ("text" in p ? p.text.length : 0), 0);
    }
    return sum;
  }, 0);
  gameStatsTracker.addAiCall({
    inputChars,
    outputChars: assistantMessage.content.length,
    promptTokens: result.usage?.prompt_tokens,
    completionTokens: result.usage?.completion_tokens,
  });
  gameSessionTracker.addAiCall({
    inputChars,
    outputChars: assistantMessage.content.length,
    promptTokens: result.usage?.prompt_tokens,
    completionTokens: result.usage?.completion_tokens,
  });

  return {
    content: stripReasoningArtifacts(assistantMessage.content),
    reasoning_details: assistantMessage.reasoning_details,
    raw: result,
  };
}

export async function generateCompletionBatch(
  requests: GenerateOptions[]
): Promise<BatchCompletionResult[]> {
  if (!Array.isArray(requests) || requests.length === 0) return [];

  const customEnabled = isCustomKeyEnabled();
  const resolvedRequests = customEnabled
    ? requests
    : requests.map((r) => ({ ...r, model: resolveModelForBuiltin(r.model) }));
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...buildCustomKeyHeaders(customEnabled),
  };

  Object.assign(headers, await getAuthHeaders());
  attachGameSessionHeader(headers);

  const response = await fetchWithRetry(
    "/api/chat",
    {
      method: "POST",
      headers,
      body: JSON.stringify({ requests: resolvedRequests }),
    },
    3
  );

  if (!response.ok) {
    const errorText = await response.text().catch(() => "");
    throw new Error(formatApiError(response.status, errorText));
  }

  const data: unknown = await response.json();
  const results = isRecord(data) && Array.isArray(data.results) ? data.results : [];

  return results.map((item): BatchCompletionResult => {
    if (!isRecord(item) || item.ok !== true) {
      return {
        ok: false,
        error: String(isRecord(item) ? (item.error ?? "Unknown error") : "Unknown error"),
        status: isRecord(item) && typeof item.status === "number" ? item.status : undefined,
      };
    }
    const raw = item.data as ChatCompletionResponse;
    const choice = raw?.choices?.[0];
    const assistantMessage = choice?.message;
    if (!assistantMessage) {
      return { ok: false, error: "No response from model" };
    }
    return {
      ok: true,
      content: stripReasoningArtifacts(assistantMessage.content),
      reasoning_details: assistantMessage.reasoning_details,
      raw,
    };
  });
}

export async function* generateCompletionStream(
  options: GenerateOptions
): AsyncGenerator<string, void, unknown> {
  const maxTokens =
    typeof options.max_tokens === "number" && Number.isFinite(options.max_tokens)
      ? Math.max(16, Math.floor(options.max_tokens))
      : undefined;

  const customEnabled = isCustomKeyEnabled();
  const modelToUse = customEnabled
    ? options.model
    : resolveModelForBuiltin(options.model);
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...buildCustomKeyHeaders(customEnabled),
  };

  Object.assign(headers, await getAuthHeaders());
  attachGameSessionHeader(headers);

  const response = await fetchWithRetry(
    "/api/chat",
    {
      method: "POST",
      headers: {
        ...headers,
      },
      body: JSON.stringify({
        model: modelToUse,
        ...(options.provider ? { provider: options.provider } : {}),
        messages: options.messages,
        temperature: options.temperature ?? 0.7,
        max_tokens: maxTokens,
        stream: true,
        ...(options.reasoning ? { reasoning: options.reasoning } : {}),
        ...(options.reasoning_effort ? { reasoning_effort: options.reasoning_effort } : {}),
        ...(options.response_format ? { response_format: options.response_format } : {}),
      }),
    },
    4
  );

  if (!response.ok) {
    const errorText = await response.text().catch(() => "");
    throw new Error(formatApiError(response.status, errorText));
  }

  const reader = response.body?.getReader();
  if (!reader) {
    throw new Error("No response body");
  }

  const decoder = new TextDecoder();
  let buffer = "";
  let totalOutputChars = 0;

  // <think> 块剥离状态机（用于 MiniMax 等把思考嵌在 content 里的模型）
  let thinkStripped = false;
  let thinkBuffer = "";

  // 计算输入字符数
  const inputChars = options.messages.reduce((sum, m) => {
    if (typeof m.content === "string") return sum + m.content.length;
    if (Array.isArray(m.content)) {
      return sum + m.content.reduce((s, p) => s + ("text" in p ? p.text.length : 0), 0);
    }
    return sum;
  }, 0);

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() || "";

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed || trimmed === "data: [DONE]") continue;
      if (!trimmed.startsWith("data: ")) continue;

      try {
        const json = JSON.parse(trimmed.slice(6));
        const delta = json.choices?.[0]?.delta?.content;
        if (delta) {
          if (thinkStripped) {
            const cleaned = stripReasoningArtifactsPreserveWhitespace(delta);
            totalOutputChars += cleaned.length;
            if (cleaned) yield cleaned;
          } else {
            thinkBuffer += delta;
            const reasoningEnd = findReasoningEnd(thinkBuffer);
            if (reasoningEnd) {
              // 找到 </think>，丢弃之前内容，从之后开始输出
              const after = stripReasoningArtifactsPreserveWhitespace(thinkBuffer.slice(reasoningEnd.end).replace(/^\n+/, ""));
              thinkStripped = true;
              thinkBuffer = "";
              if (after) {
                totalOutputChars += after.length;
                yield after;
              }
            } else if (!couldBeReasoningStart(thinkBuffer) && thinkBuffer.length >= 1) {
              // 确认不是 <think> 块，直接透传
              thinkStripped = true;
              const cleaned = stripReasoningArtifactsPreserveWhitespace(thinkBuffer);
              totalOutputChars += cleaned.length;
              if (cleaned) yield cleaned;
              thinkBuffer = "";
            }
            // 否则继续缓冲，等待 </think> 或确认非 think 块
          }
        }
      } catch {
        // Skip malformed JSON
      }
    }
  }

  // 流式结束后统计 AI 调用
  gameStatsTracker.addAiCall({
    inputChars,
    outputChars: totalOutputChars,
  });
  gameSessionTracker.addAiCall({
    inputChars,
    outputChars: totalOutputChars,
  });
}

export async function generateJSON<T>(
  options: GenerateOptions & { schema?: string }
): Promise<T> {
  const messagesWithFormat = [...options.messages];

  const lastMessage = messagesWithFormat[messagesWithFormat.length - 1];
  if (lastMessage && lastMessage.role === "user") {
    const suffix =
      "\n\nRespond with valid JSON only. No markdown, no code blocks, just raw JSON. If you need to include double quotes inside string values, escape them as \\\".";
    if (typeof lastMessage.content === "string") {
      lastMessage.content += suffix;
    } else if (Array.isArray(lastMessage.content)) {
      const parts = lastMessage.content;
      const lastPart = parts[parts.length - 1];
      if (lastPart && lastPart.type === "text") {
        lastPart.text += suffix;
      } else {
        parts.push({ type: "text", text: suffix });
      }
    }
  }

  const shouldForceJsonObject =
    !options.response_format && getProviderForModel(options.model) === "zenmux";

  const result = await generateCompletion({
    ...options,
    ...(shouldForceJsonObject ? { response_format: { type: "json_object" as const } } : {}),
    messages: messagesWithFormat,
  });

  try {
    return parseJsonTolerant<T>(result.content);
  } catch (firstError) {
    const retryMessages: LLMMessage[] = [
      ...messagesWithFormat,
      { role: "assistant", content: result.content.slice(0, 4000) },
      {
        role: "user",
        content: "The previous response was not valid JSON for the requested schema. Return valid JSON only, with no markdown and no extra text.",
      },
    ];

    const retryResult = await generateCompletion({
      ...options,
      ...(shouldForceJsonObject ? { response_format: { type: "json_object" as const } } : {}),
      messages: retryMessages,
    });

    try {
      return parseJsonTolerant<T>(retryResult.content);
    } catch {
      throw firstError;
    }
  }
}
