/**
 * ============================================================
 *  src/llm/client.ts —— OpenAI 兼容 LLM 客户端（流式）
 * ------------------------------------------------------------
 *  这个文件做一件事：把"消息历史 + 可用工具"发给 LLM，把"流式响应"逐 chunk
 *  yield 回上层。它是整个 agent 系统里唯一会 fetch() 远端 API 的地方。
 *
 *  我们用 OpenAI 的 Chat Completions 协议，因为：
 *    - DeepSeek、月之暗面、智谱、火山、OpenRouter 都按它兼容
 *    - 协议简单：POST 一个 JSON，回 SSE
 *
 *  SSE（Server-Sent Events）是什么？
 *    一种很简单的流式协议：服务器逐行返回 "data: <json>\n\n"，
 *    最后用 "data: [DONE]\n\n" 表示结束。
 *
 *  本文件不依赖任何 npm 包（fetch 是 Bun/Node 20+ 内置）。
 * ============================================================
 */
import { dlog } from '../utils/debugLog.js';
/**
 * 记住"这个 baseURL::model 不支持 response_format.json_schema"，避免每次都先撞 400。
 * 单进程内有效；每个 -p 子进程是全新进程 → 首个结构化调用吃一次 400 再退化，可接受。
 */
const rfUnsupported = new Set();
/** 调试日志用的请求自增号（vote 并发多 voter 时靠它在日志里区分是哪一路）。 */
let reqCounter = 0;
/**
 * 判断一个 400 是否在抱怨 response_format（json_schema）不被支持。
 * 各 provider 文案不一（火山 GLM：`json_schema is not supported by this model`），
 * 故宽松匹配："提到 response_format" 且 "出现 not support / unsupported / not valid / invalid"。
 */
function isResponseFormatUnsupported(status, errText) {
    if (status !== 400)
        return false;
    const t = errText.toLowerCase();
    if (!t.includes('response_format'))
        return false;
    return (t.includes('not support') || // 命中 "not supported"
        t.includes('unsupported') ||
        t.includes('not valid') ||
        t.includes('invalid'));
}
/**
 * 调用 LLM 并以流的形式 yield 事件。
 *
 * @param provider 选好的 provider（含 baseURL / apiKey / model）
 * @param messages 完整的对话历史
 * @param tools    工具列表（每个工具需要带 jsonSchema 才能给 LLM）
 * @param signal   AbortSignal，UI 按 Ctrl+C 时中断
 *
 * @yield LlmStreamEvent: text_delta / tool_call_delta / done
 *
 * 用法示例：
 * ```
 * for await (const ev of chat({ provider, messages, tools })) {
 *   if (ev.type === 'text_delta') process.stdout.write(ev.delta);
 * }
 * ```
 */
export async function* chat(args) {
    const { provider, messages, tools, signal, responseFormat } = args;
    // 1. 把 Tool 数组转成 OpenAI 协议的 tools 字段
    const openaiTools = tools.map((t) => ({
        type: 'function',
        function: {
            name: t.name,
            description: typeof t.description === 'function' ? t.description() : t.description,
            parameters: t.parameters,
        },
    }));
    // 2. 过滤并清洁 messages 数组，剥离 timestamp, id, _ephemeral 等本地渲染元数据
    const cleanedMessages = messages
        .filter((m) => {
        // 过滤掉 _ephemeral=true 的临时 UI 提示消息，防止其污染 LLM 核心上下文
        if (m.role === 'system' && m._ephemeral === true) {
            return false;
        }
        return true;
    })
        .map((m) => {
        // 结构解构以去除本地专属渲染元数据
        const { id, timestamp, ...rest } = m;
        return rest;
    });
    // 3. 构造请求体
    // 逃生开关：极个别 provider 不认 stream_options 会 400，可用 MINIMAL_AGENT_DISABLE_USAGE=1 关掉。
    const includeUsage = process.env.MINIMAL_AGENT_DISABLE_USAGE !== '1';
    const url = `${provider.baseURL.replace(/\/$/, '')}/chat/completions`;
    // response_format 能力探测：部分 OpenAI 兼容 provider（如火山 GLM 系）不支持
    // response_format.type=json_schema，会回 400。已知不支持的 model 直接跳过，
    // 撞到 400 则退化重试（去掉 response_format，靠 prompt 内嵌 schema + 上层 extractJsonPayload 容错解析）。
    const rfKey = `${provider.baseURL}::${provider.model}`;
    const wantResponseFormat = responseFormat !== undefined && !rfUnsupported.has(rfKey);
    const postChat = (withResponseFormat) => fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${provider.apiKey}`,
        },
        body: JSON.stringify({
            model: provider.model,
            messages: cleanedMessages,
            tools: openaiTools.length > 0 ? openaiTools : undefined,
            stream: true,
            // stream_options.include_usage：让 provider 在流末附带真实 prompt_tokens，
            // 供上层自动压缩判断用；provider 不支持时 usagePromptTokens 保持 undefined，上层 fallback 本地估算。
            ...(includeUsage ? { stream_options: { include_usage: true } } : {}),
            // tool_choice: 'auto' 是默认值，可不写；某些 provider 必须显式
            tool_choice: openaiTools.length > 0 ? 'auto' : undefined,
            // ADR-05: structured output；缺省 undefined 不出现在 body（JSON.stringify 自动剥离）
            response_format: withResponseFormat ? responseFormat : undefined,
        }),
        signal,
    });
    // 3. 发请求（fetch 在 Bun/Node 20+ 内置）
    const reqId = ++reqCounter;
    dlog('llm', `req#${reqId} POST →`, {
        model: provider.model,
        rf: wantResponseFormat,
        msgs: cleanedMessages.length,
        tools: openaiTools.length,
    });
    let resp = await postChat(wantResponseFormat);
    // 4. 错误处理：先处理 "json_schema 不支持" 的退化重试，再翻译其它 HTTP 错误
    if (!resp.ok) {
        const errText = await resp.text().catch(() => '');
        if (wantResponseFormat && isResponseFormatUnsupported(resp.status, errText)) {
            // 记住该 model 不支持，单进程内后续结构化调用直接跳过 response_format（不再撞 400）。
            rfUnsupported.add(rfKey);
            console.warn(`[minimal-agent] provider model "${provider.model}" 不支持 response_format.json_schema，` +
                `已退化为 prompt 引导 + 容错解析（结构化输出的硬保证降级为尽力而为）。`);
            resp = await postChat(false);
            if (!resp.ok) {
                const e2 = await resp.text().catch(() => '');
                throw new Error(`LLM 请求失败 [${resp.status} ${resp.statusText}]：${e2.slice(0, 500)}`);
            }
        }
        else {
            throw new Error(`LLM 请求失败 [${resp.status} ${resp.statusText}]：${errText.slice(0, 500)}`);
        }
    }
    if (!resp.body) {
        throw new Error('LLM 响应没有 body（provider 可能不支持流式？）');
    }
    // fetch 已返回且 ok：若日志卡在这条之前 = fetch 阶段挂起（无超时）；卡在这条之后无 done = 流中途挂起。
    dlog('llm', `req#${reqId} streaming begin (status ${resp.status})`);
    // 5. SSE 解析：把 ReadableStream 切成一行行 "data: ..." 然后 JSON.parse
    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    /** 临时缓冲：HTTP chunk 不一定按行切，要自己拼 */
    let buffer = '';
    /** 累积的 stop_reason，最后 yield done 时用 */
    let stopReason = 'unknown';
    /** 真实 prompt_tokens（从 usage chunk 读取）；provider 不返回时保持 undefined */
    let usagePromptTokens;
    try {
        while (true) {
            // 每次 read 前检查 abort：用户按 ESC/Ctrl+C 时 signal.aborted 为 true。
            // 这样即使 fetch 还没完成，loop 也能及时退出而不是一直等到网络超时。
            if (signal?.aborted) {
                yield { type: 'done', stopReason: 'aborted' };
                return;
            }
            const { value, done } = await reader.read();
            if (done)
                break;
            // 每个 chunk 处理完后也检查 abort，让快速连按能及时退出。
            if (signal?.aborted) {
                yield { type: 'done', stopReason: 'aborted' };
                return;
            }
            buffer += decoder.decode(value, { stream: true });
            // SSE 用 "\n\n" 分事件，按行拆 buffer
            let lineEnd;
            while ((lineEnd = buffer.indexOf('\n')) !== -1) {
                const line = buffer.slice(0, lineEnd).trim();
                buffer = buffer.slice(lineEnd + 1);
                if (!line || !line.startsWith('data:'))
                    continue;
                const dataStr = line.slice(5).trim(); // 去掉 "data:" 前缀
                if (dataStr === '[DONE]') {
                    // 流结束标记；带出真实 prompt_tokens（provider 不返回时为 undefined，上层 fallback 本地估算）
                    dlog('llm', `req#${reqId} done [DONE]`, { stopReason, promptTokens: usagePromptTokens });
                    yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
                    return;
                }
                // 解析一个 chunk JSON
                let chunk;
                try {
                    chunk = JSON.parse(dataStr);
                }
                catch {
                    // 罕见：某些 provider 会发空行或心跳，忽略
                    continue;
                }
                // usage chunk：include_usage 开启时，provider 通常在流末单独发一帧，
                // 此帧 choices 为空（没有 delta），必须在 `if (!delta) continue` 之前读取，
                // 否则会被 continue 跳过，永远拿不到真实 prompt_tokens。
                if (chunk.usage && typeof chunk.usage.prompt_tokens === 'number') {
                    usagePromptTokens = chunk.usage.prompt_tokens;
                }
                const delta = chunk.choices?.[0]?.delta;
                const finish = chunk.choices?.[0]?.finish_reason;
                if (finish) {
                    stopReason =
                        finish === 'stop' || finish === 'tool_calls' || finish === 'length'
                            ? finish
                            : 'unknown';
                }
                if (!delta)
                    continue;
                // 5a. 文本增量
                //   注意：MiniMax 等模型默认会把 <think>...</think> 标签直接放在
                //   content 字段里 —— 我们原样累积，不剥离，确保下一轮工具调用时
                //   思维链完整回传。
                if (typeof delta.content === 'string' && delta.content.length > 0) {
                    yield { type: 'text_delta', delta: delta.content };
                }
                // 5b. tool_calls 增量（OpenAI 协议下 tool_calls 也是分片的）
                if (Array.isArray(delta.tool_calls)) {
                    for (const tc of delta.tool_calls) {
                        yield {
                            type: 'tool_call_delta',
                            index: tc.index ?? 0,
                            id: tc.id,
                            name: tc.function?.name,
                            argumentsDelta: tc.function?.arguments,
                        };
                    }
                }
                // 5c. 思维链增量（DeepSeek-R1 / MiniMax reasoning_split / OpenRouter）
                //   三种字段名都见过：reasoning_content / reasoning / reasoning_details
                //   reasoning_details 是数组，每片是个对象；其它两个是字符串增量。
                if (typeof delta.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
                    yield {
                        type: 'reasoning_delta',
                        field: 'reasoning_content',
                        delta: delta.reasoning_content,
                    };
                }
                if (typeof delta.reasoning === 'string' && delta.reasoning.length > 0) {
                    yield {
                        type: 'reasoning_delta',
                        field: 'reasoning',
                        delta: delta.reasoning,
                    };
                }
                if (Array.isArray(delta.reasoning_details) && delta.reasoning_details.length > 0) {
                    yield {
                        type: 'reasoning_delta',
                        field: 'reasoning_details',
                        items: delta.reasoning_details,
                    };
                }
            }
        }
        // 如果流没显式发 [DONE]，也补一个 done；同样带出 promptTokens
        dlog('llm', `req#${reqId} done (stream end, no [DONE])`, { stopReason, promptTokens: usagePromptTokens });
        yield { type: 'done', stopReason, promptTokens: usagePromptTokens };
    }
    finally {
        reader.releaseLock?.();
    }
}
