package com.gzzm.lobster.context;

import com.gzzm.lobster.common.JsonUtil;
import com.gzzm.lobster.common.MessageRole;
import com.gzzm.lobster.common.TokenEstimator;
import com.gzzm.lobster.common.ResourceSourceType;
import com.gzzm.lobster.config.LobsterConfig;
import com.gzzm.lobster.identity.UserContext;
import com.gzzm.lobster.llm.LobsterMessage;
import com.gzzm.lobster.llm.ModelRouteResult;
import com.gzzm.lobster.llm.ToolCall;
import com.gzzm.lobster.memory.MemoryService;
import com.gzzm.lobster.memory.PersonalMemory;
import com.gzzm.lobster.oa.OaKnowledgeClient;
import com.gzzm.lobster.oa.OaKnowledgeHit;
import com.gzzm.lobster.plan.PlanService;
import com.gzzm.lobster.prompt.PromptTemplateService;
import com.gzzm.lobster.skill.SkillService;
import com.gzzm.lobster.storage.FileSystemContentStore;
import com.gzzm.lobster.thread.ThreadMessage;
import com.gzzm.lobster.thread.ThreadRoom;
import com.gzzm.lobster.thread.ThreadService;
import com.gzzm.lobster.workspace.ResourceMetadata;
import com.gzzm.lobster.workspace.WorkspaceResource;
import com.gzzm.lobster.workspace.WorkspaceService;
import com.gzzm.platform.commons.Tools;
import net.cyan.nest.annotation.Inject;

import java.util.*;

/**
 * ContextAssembler —— 上下文组装器 / Context assembler.
 *
 * <p>将 thread transcript + workspace 索引 + skill 索引 + memory + 动态规则 +
 * 系统骨架组装为 send-view。严格遵循「thread-first、send-view 分层、稳定前缀、
 * 按需注入、发送前硬闸」的设计原则。
 *
 * <p>Assembles the final send-view from thread transcript + workspace index +
 * skill index + memory + dynamic rules + system skeleton.
 */
public class ContextAssembler {

    @Inject private ThreadService threadService;
    @Inject private WorkspaceService workspaceService;
    @Inject private SkillService skillService;
    @Inject private MemoryService memoryService;
    @Inject private PromptTemplateService promptTemplateService;
    @Inject private PlanService planService;
    /** 用具体类注入（feedback_nest_interface_inject）；多模态从 ContentStore 读图片二进制 → base64. */
    @Inject private FileSystemContentStore contentStore;
    /** 默认 LlmSummarizer——配置 summarizerEnabled=false 时其内部退化为 bullet. */
    @Inject private LlmSummarizer summarizer;
    /** 知识库客户端（按接口注入，nest.xml 已绑定）。kbEnabled+forced 模式才会用到. */
    @Inject private OaKnowledgeClient oaKnowledgeClient;

    /** 兼容旧调用：不传 route 时摘要走 bullet（无 LLM 调用）. */
    public ContextAssembly assemble(ThreadRoom thread, String currentUserInput,
                                    int totalBudgetTokens) throws Exception {
        return assemble(thread, currentUserInput, totalBudgetTokens, null);
    }

    /** 兼容旧调用：不带 KB 设定. */
    public ContextAssembly assemble(ThreadRoom thread, String currentUserInput,
                                    int totalBudgetTokens, ModelRouteResult route) throws Exception {
        return assemble(thread, currentUserInput, totalBudgetTokens, route,
                false, "auto", java.util.Collections.<String>emptyList(), null);
    }

    /** 层序固定：system → rule → skill-index → memory → workspace → kb-prelude → transcript → tool.
     *
     * <p>多模态由 transcript 单一来源驱动：buildTranscriptView 看到 user 消息的
     * {@code attachmentsJson.imageMediaIds} 就 {@link #readImageAsDataUrl} 读盘转 base64，
     * 用 {@link LobsterMessage#userWithImages} 包装. 调用方只需在 appendMessage 时
     * 把 mediaIds 写进 attachmentsJson，无需在此再传 imageDataUrls 参数.
     *
     * <p>知识库模式：
     * <ul>
     *   <li>{@code kbEnabled=false}：什么也不做（OA KB 工具同时被 AgentRuntime 过滤掉）</li>
     *   <li>{@code kbEnabled=true && kbMode="auto"}：什么也不注入；LLM 看到工具自己决定调不调</li>
     *   <li>{@code kbEnabled=true && kbMode="forced"}：先 search 一次，把 top N hits 作为
     *       system 段（"## 知识库参考"）注入到 transcript 之前，让模型一定看见</li>
     * </ul>
     */
    public ContextAssembly assemble(ThreadRoom thread, String currentUserInput,
                                    int totalBudgetTokens, ModelRouteResult route,
                                    boolean kbEnabled, String kbMode, List<String> kbScopeIds,
                                    UserContext kbUser) throws Exception {
        return assemble(thread, currentUserInput, totalBudgetTokens, route, kbEnabled, kbMode,
                kbScopeIds, kbUser, createRunSnapshot(thread));
    }

    public ContextAssembly assemble(ThreadRoom thread, String currentUserInput,
                                    int totalBudgetTokens, ModelRouteResult route,
                                    boolean kbEnabled, String kbMode, List<String> kbScopeIds,
                                    UserContext kbUser, RunSnapshot runSnapshot) throws Exception {
        ContextBudgetPolicy budget = new ContextBudgetPolicy(totalBudgetTokens);
        ToolResultWidthPolicy widthPolicy = new ToolResultWidthPolicy(budget.getToolResultWidth());
        // 折叠时优先 LLM 摘要（route 非空 + summarizer 启用），失败 bullet 兜底.
        ContextCompactionPolicy compaction = new ContextCompactionPolicy(
                widthPolicy, 3, 2, summarizer, thread, route);
        SkillIndexPolicy skillIdx = new SkillIndexPolicy();

        List<LobsterMessage> out = new ArrayList<>();
        List<LobsterMessage> lateSystemMessages = new ArrayList<>();
        Map<String, Integer> breakdown = new LinkedHashMap<>();
        List<String> compactionLog = new ArrayList<>();

        // 1) System skeleton（稳定字节，利于 prompt-cache）
        String systemSkeleton = promptTemplateService.loadSystemSkeleton();
        out.add(LobsterMessage.system(systemSkeleton));
        breakdown.put("system", TokenEstimator.estimate(systemSkeleton));

        // 2) Dynamic rule
        String rules = promptTemplateService.dynamicRules(thread.getOrgId());
        out.add(LobsterMessage.system(rules));
        breakdown.put("rules", TokenEstimator.estimate(rules));

        // 3) Skill thin index —— 只给 id/name/description（触发指引）。
        // guidance 全文不再注入 system；模型按 description 判断、主动调用 use_skill 读取。
        StringBuilder skillSection = new StringBuilder();
        try {
            List<SkillService.SkillSummary> summaries = skillService.listThinIndex();
            skillSection.append(skillIdx.buildThinIndex(summaries));
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] skill section failed", t); } catch (Throwable ignore) { /* ignore */ }
        }
        if (skillSection.length() > 0) {
            out.add(LobsterMessage.system(skillSection.toString()));
            breakdown.put("skills", TokenEstimator.estimate(skillSection.toString()));
        }

        // 4/5) Memory and Workspace are run-level snapshots. Tool turns in the same run may
        // create workspace resources or update memory, but refreshing these volatile sections
        // inside the run would rewrite the prompt prefix and defeat cache reuse.
        RunSnapshot snapshot = runSnapshot == null ? createRunSnapshot(thread) : runSnapshot;
        lateSystemMessages.addAll(snapshot.getLateSystemMessages());
        breakdown.putAll(snapshot.getBreakdown());

        if (runSnapshot == null) {
            compactionLog.add("memory/workspace snapshot rebuilt for assemble fallback");
        }

        // 5.5) Plan (optional)
        try {
            PlanService.PlanSnapshot plan = planService.loadActive(thread.getThreadId());
            if (plan != null && !plan.getItems().isEmpty()) {
                String planSection = "## 当前计划：" + plan.getPlan().getTitle() + "（"
                        + plan.getCompletedCount() + "/" + plan.getTotalCount() + "）\n"
                        + JsonUtil.toJson(plan.toMap());
                out.add(LobsterMessage.system(planSection));
                breakdown.put("plan", TokenEstimator.estimate(planSection));
            }
        } catch (Throwable ignore) { /* plan optional */ }

        // 5.7) KB forced 注入：开启知识库 + forced 模式时预拉一次 hits 作为 system 段塞进去.
        // 与「auto 模式」的差异：auto 把决策让给 LLM；forced 强制让 LLM 看到引用，适合
        // "客服 / 制度问答 / 合规要求每条回答都给来源" 这种强引用场景.
        if (kbEnabled && "forced".equalsIgnoreCase(kbMode)
                && currentUserInput != null && !currentUserInput.isEmpty()
                && oaKnowledgeClient != null) {
            String kbSection = buildKbForcedPrelude(kbUser, currentUserInput, kbScopeIds);
            if (kbSection != null && !kbSection.isEmpty()) {
                out.add(LobsterMessage.system(kbSection));
                breakdown.put("kb_prelude", TokenEstimator.estimate(kbSection));
            }
        }

        // 6) Transcript send-view
        List<LobsterMessage> transcript = buildTranscriptView(thread);
        List<LobsterMessage> projected = new ArrayList<>(
                compaction.projectTranscript(transcript, budget.getTranscriptSoft()));
        if (projected.size() != transcript.size()) {
            compactionLog.add("transcript collapsed: " + transcript.size() + " -> " + projected.size());
        }
        int currentUserIndex = findCurrentUserIndex(projected, currentUserInput);
        if (currentUserIndex >= 0) {
            out.addAll(projected.subList(0, currentUserIndex));
        } else {
            out.addAll(projected);
        }
        // Memory/Workspace indexes are volatile. Keep them after transcript so refreshing either
        // index does not rewrite the cached prefix that contains prior conversation messages.
        // If this run's current user message is already in transcript, put volatile sections right
        // before that current-run block. ReAct follow-up turns may already have assistant/tool
        // messages after the user message, so checking only the tail is not enough.
        out.addAll(lateSystemMessages);
        if (currentUserIndex >= 0) out.addAll(projected.subList(currentUserIndex, projected.size()));
        int transcriptTokens = 0;
        for (LobsterMessage m : projected) transcriptTokens += TokenEstimator.estimate(m.getContent());
        breakdown.put("transcript", transcriptTokens);

        // 7) 当前用户输入兜底——AgentRuntime 已在 run 开头把 user 消息落库，
        // 绝大多数情况下 transcript 里已有。只有 transcript 被压缩掉或外部调用方没落库
        // 才需要这里补追加。注意：不能只看"最后一条是不是 user"，因为经过 tool 调用后
        // 最后一条会是 tool result，那时用户输入依然在更前面（曾经的 bug 就是在这里
        // 每轮都重复追加一次）。
        if (currentUserInput != null && !currentUserInput.isEmpty()) {
            boolean alreadyPresent = false;
            for (int i = out.size() - 1; i >= 0; i--) {
                LobsterMessage m = out.get(i);
                if (m.getRole() != MessageRole.user) continue;
                String c = m.getContent() == null ? "" : m.getContent();
                // endsWith 兼容 AgentRuntime 给 user 输入前缀 attachment prelude 的情况
                if (c.equals(currentUserInput) || c.endsWith(currentUserInput)) {
                    alreadyPresent = true;
                }
                break;  // 只看最近一条 user 消息
            }
            if (!alreadyPresent) {
                // 这条兜底路径罕见——多数情况 AgentRuntime 在 assemble 之前已经 appendMessage(role=user).
                // 真走到这条说明 transcript 里漏了当前 user，也意味着没有 attachmentsJson 可读,
                // 所以只能按纯文本入；多模态走 transcript 单一来源（buildTranscriptView 自动还原图片）.
                out.add(LobsterMessage.user(currentUserInput));
                breakdown.put("current_input", TokenEstimator.estimate(currentUserInput));
            }
        }

        // 8) 预算硬闸
        int total = 0;
        for (Integer v : breakdown.values()) total += (v == null ? 0 : v);
        if (!budget.checkHardGate(total)) {
            // 尝试一次更激进的压缩：只保留最近 1 轮
            ContextCompactionPolicy hard = new ContextCompactionPolicy(
                    widthPolicy, 1, 1, summarizer, thread, route);
            List<LobsterMessage> shrunk = new ArrayList<>(
                    hard.projectTranscript(transcript, budget.getTranscriptSoft() / 2));
            int hardCurrentUserIndex = findCurrentUserIndex(shrunk, currentUserInput);
            int idx = findFirstTranscriptIndex(out);
            if (idx >= 0) {
                out = new ArrayList<>(out.subList(0, idx));
                if (hardCurrentUserIndex >= 0) {
                    out.addAll(shrunk.subList(0, hardCurrentUserIndex));
                } else {
                    out.addAll(shrunk);
                }
                out.addAll(lateSystemMessages);
                if (hardCurrentUserIndex >= 0) out.addAll(shrunk.subList(hardCurrentUserIndex, shrunk.size()));
                // shrunk 已包含最近一轮 user 输入；只有真的漏了才补——条件同 section 7.
                if (currentUserInput != null && !currentUserInput.isEmpty()) {
                    boolean alreadyPresent = false;
                    for (int i = out.size() - 1; i >= 0; i--) {
                        LobsterMessage m = out.get(i);
                        if (m.getRole() != MessageRole.user) continue;
                        String c = m.getContent() == null ? "" : m.getContent();
                        if (c.equals(currentUserInput) || c.endsWith(currentUserInput)) {
                            alreadyPresent = true;
                        }
                        break;
                    }
                    if (!alreadyPresent) out.add(LobsterMessage.user(currentUserInput));
                }
                compactionLog.add("hard-gate triggered, transcript shrunk to 1 recent turn");
                total = 0;
                for (LobsterMessage m : out) total += TokenEstimator.estimate(m.getContent());
            }
        }

        return new ContextAssembly(out, breakdown, compactionLog, total);
    }

    public RunSnapshot createRunSnapshot(ThreadRoom thread) {
        List<LobsterMessage> messages = new ArrayList<>();
        Map<String, Integer> breakdown = new LinkedHashMap<>();
        MemoryIndexPolicy memIdx = new MemoryIndexPolicy(120);
        WorkspaceIndexPolicy wsIdx = new WorkspaceIndexPolicy();

        StringBuilder memorySection = new StringBuilder();
        try {
            com.gzzm.lobster.identity.UserContext memUser =
                    new com.gzzm.lobster.identity.UserContext(thread.getUserId(), null, null,
                            thread.getOrgId(), null, Collections.<String>emptySet());
            List<PersonalMemory> entries = memoryService.listIndex(memUser, memIdx.getMaxEntries());
            String mem = memIdx.buildSection(entries);
            if (!mem.isEmpty()) memorySection.append(mem);
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] memory snapshot failed", t); } catch (Throwable ignore) { /* ignore */ }
        }
        if (memorySection.length() > 0) {
            String text = memorySection.toString();
            messages.add(LobsterMessage.system(text));
            breakdown.put("memory", TokenEstimator.estimate(text));
        }

        try {
            long total = workspaceService.countResources(thread.getThreadId());
            List<WorkspaceResource> recent = workspaceService.listResources(thread.getThreadId(), null, 0, 10);
            String ws = wsIdx.build(total, recent);
            messages.add(LobsterMessage.system(ws));
            breakdown.put("workspace", TokenEstimator.estimate(ws));
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] workspace snapshot failed", t); } catch (Throwable ignore) { /* ignore */ }
        }

        return new RunSnapshot(messages, breakdown);
    }

    public static final class RunSnapshot {
        private final List<LobsterMessage> lateSystemMessages;
        private final Map<String, Integer> breakdown;

        RunSnapshot(List<LobsterMessage> lateSystemMessages, Map<String, Integer> breakdown) {
            this.lateSystemMessages = lateSystemMessages == null
                    ? Collections.<LobsterMessage>emptyList()
                    : Collections.unmodifiableList(new ArrayList<>(lateSystemMessages));
            this.breakdown = breakdown == null
                    ? Collections.<String, Integer>emptyMap()
                    : Collections.unmodifiableMap(new LinkedHashMap<>(breakdown));
        }

        public List<LobsterMessage> getLateSystemMessages() {
            return lateSystemMessages;
        }

        public Map<String, Integer> getBreakdown() {
            return breakdown;
        }
    }

    private static int findCurrentUserIndex(List<LobsterMessage> messages, String currentUserInput) {
        if (messages == null || messages.isEmpty()) return -1;
        if (currentUserInput == null || currentUserInput.isEmpty()) return -1;
        for (int i = messages.size() - 1; i >= 0; i--) {
            LobsterMessage m = messages.get(i);
            if (m == null || m.getRole() != MessageRole.user) continue;
            String c = m.getContent() == null ? "" : m.getContent();
            if (c.equals(currentUserInput) || c.endsWith(currentUserInput)) return i;
        }
        return -1;
    }

    /**
     * Forced 模式：拉一次 KB search 结果拼成 markdown 段塞进 system 区。
     *
     * <p>带缓存：单 run 内多轮 ReAct 的 (userInput + scopeIds + userId) 完全相同，
     * 没必要每轮都打一次 KB（典型 5 轮 = 5 次 RTT + 5 倍成本）。
     * 5 分钟 TTL 同时覆盖了「同一用户连续问同一问题」的人工重试场景.
     *
     * <p>失败完全静默——KB 不可用不应阻塞主对话；模型仍能基于自身能力回答，
     * 只是没拿到引用. 命中 0 条时返回空串（避免污染 system 段）；空串也会缓存
     * 避免反复无效查询.
     */
    private String buildKbForcedPrelude(UserContext user, String query, List<String> scopeIds) {
        String cacheKey = preludeCacheKey(user, query, scopeIds);
        long now = System.currentTimeMillis();

        CachedPrelude cached = FORCED_PRELUDE_CACHE.get(cacheKey);
        if (cached != null) {
            if (cached.expiresAtMs > now) return cached.content;
            FORCED_PRELUDE_CACHE.remove(cacheKey);
        }

        String rendered;
        try {
            int max = LobsterConfig.getKbForcedInjectMaxHits();
            if (max <= 0) max = 3;
            List<OaKnowledgeHit> hits = oaKnowledgeClient.search(user, query, scopeIds, max);
            if (hits == null || hits.isEmpty()) {
                rendered = "";
            } else {
                StringBuilder sb = new StringBuilder(512);
                sb.append("## 知识库参考（请在回答中按 [docId] 引用相关条目）\n");
                for (OaKnowledgeHit h : hits) {
                    sb.append("- **[").append(h.getDocId()).append("] ").append(safeStr(h.getTitle())).append("**");
                    if (h.getScopeName() != null && !h.getScopeName().isEmpty()) {
                        sb.append("（").append(h.getScopeName()).append("）");
                    }
                    String snippet = safeStr(h.getSnippet());
                    if (!snippet.isEmpty()) {
                        sb.append("\n  ").append(snippet.length() > 280 ? snippet.substring(0, 280) + "…" : snippet);
                    }
                    sb.append('\n');
                }
                sb.append("\n如需完整正文，调用 oa_get_knowledge_detail(docId)。");
                rendered = sb.toString();
            }
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] KB forced prelude failed", t); }
            catch (Throwable ignore) { /* ignore */ }
            // 失败不缓存——下一轮还可以重试，避免一次抖动导致整个 run 都拿不到引用.
            return "";
        }
        FORCED_PRELUDE_CACHE.put(cacheKey, new CachedPrelude(rendered, now + FORCED_PRELUDE_TTL_MS));
        return rendered;
    }

    /** 缓存 key：把会决定 search 结果的所有维度拼起来（scopeIds 排序后拼，避免顺序差异导致 miss）. */
    private static String preludeCacheKey(UserContext user, String query, List<String> scopeIds) {
        StringBuilder k = new StringBuilder();
        k.append(user == null ? "" : safeStr(user.getUserId())).append('|');
        k.append(query == null ? "" : query).append('|');
        if (scopeIds != null && !scopeIds.isEmpty()) {
            List<String> sorted = new ArrayList<>(scopeIds);
            Collections.sort(sorted);
            for (String s : sorted) k.append(s).append(',');
        }
        return k.toString();
    }

    /** 进程级 LRU + TTL 缓存 / Process-level LRU+TTL cache for FORCED-mode KB prelude. */
    private static final int FORCED_PRELUDE_CACHE_MAX = 64;
    private static final long FORCED_PRELUDE_TTL_MS   = 5 * 60 * 1000L;

    private static final class CachedPrelude {
        final String content;
        final long expiresAtMs;
        CachedPrelude(String content, long expiresAtMs) {
            this.content = content;
            this.expiresAtMs = expiresAtMs;
        }
    }

    private static final Map<String, CachedPrelude> FORCED_PRELUDE_CACHE = Collections.synchronizedMap(
            new LinkedHashMap<String, CachedPrelude>(FORCED_PRELUDE_CACHE_MAX + 1, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, CachedPrelude> eldest) {
                    return size() > FORCED_PRELUDE_CACHE_MAX;
                }
            });

    private static String safeStr(String s) { return s == null ? "" : s; }

    /**
     * 把 thread transcript 转换为 LobsterMessage 列表（send-view 输入）。
     *
     * <p>兜底：历史里若遗留"孤立 assistant(tool_calls)"——即有 tool_calls 但后续找不到
     * 对应所有 tool_call_id 的 tool 消息（可能来自旧版本的 loop_detector 中止路径、
     * crash、或手动 DB 操作），OpenAI-compatible endpoint 会返回 400。这里扫一遍
     * assistant(tool_calls)，给缺失的 tool_call_id 合成占位 tool 结果，保证 schema 有效。
     */
    private List<LobsterMessage> buildTranscriptView(ThreadRoom thread) throws Exception {
        List<ThreadMessage> messages = threadService.loadLightweightTranscript(thread.getThreadId());
        List<LobsterMessage> out = new ArrayList<>(messages.size());
        for (int i = 0; i < messages.size(); i++) {
            ThreadMessage m = messages.get(i);
            String content = m.getContent() == null ? "" : m.getContent();
            MessageRole role = m.getRole();
            if (role == MessageRole.tool) {
                // ThreadService may store large tool results as a short preview + ContentStore ref.
                // The send-view must restore the full result first; width limiting is a later
                // compaction step and should only happen when the context budget requires it.
                if (m.getContentRef() != null && !m.getContentRef().isEmpty()) {
                    out.add(LobsterMessage.toolWithRef(m.getToolCallId(), m.getToolName(),
                            threadService.resolveFullContent(m), m.getContentRef()));
                } else {
                    out.add(LobsterMessage.tool(m.getToolCallId(), m.getToolName(), content));
                }
            } else if (role == MessageRole.assistant && m.getToolCallsJson() != null && !m.getToolCallsJson().isEmpty()) {
                List<ToolCall> calls = parseToolCalls(m.getToolCallsJson());
                // thinking-mode 模型的 reasoning_content 要原样回传给 API，否则 DeepSeek 等返 400.
                String reasoning = m.getReasoningContent();
                if (reasoning != null && !reasoning.isEmpty()) {
                    out.add(LobsterMessage.assistantWithToolCallsAndReasoning(content, calls, reasoning));
                } else {
                    out.add(LobsterMessage.assistantWithToolCalls(content, calls));
                }
                // 看后续 tool 消息里有没有齐全覆盖这些 tool_call_id；缺了就补占位
                Set<String> expected = new LinkedHashSet<>();
                for (ToolCall c : calls) if (c.getId() != null) expected.add(c.getId());
                Set<String> seen = new HashSet<>();
                for (int j = i + 1; j < messages.size(); j++) {
                    ThreadMessage n = messages.get(j);
                    if (n.getRole() != MessageRole.tool) break;  // tool 消息必须紧跟 assistant
                    if (n.getToolCallId() != null) seen.add(n.getToolCallId());
                }
                for (String id : expected) {
                    if (!seen.contains(id)) {
                        out.add(LobsterMessage.tool(id, null,
                                "{\"status\":\"aborted\",\"message\":\"tool call not executed\"}"));
                    }
                }
            } else if (role == MessageRole.user) {
                // 多模态：user 消息的 attachmentsJson 里若挂了 imageMediaIds，重新读图片二进制
                // 转 base64 dataUrl，发给 vision 模型——这样切 thread / 重启 run 都能让模型
                // 回看历史图片. 不是图片的附件不进 attachmentsJson（走 prelude / workspace index）.
                List<String> imageUrls = resolveImageDataUrlsFromAttachmentsJson(m.getAttachmentsJson());
                if (!imageUrls.isEmpty()) {
                    out.add(LobsterMessage.userWithImages(content, imageUrls));
                } else {
                    out.add(LobsterMessage.user(content));
                }
            } else if (role == MessageRole.assistant) {
                // 无 tool_calls 的 assistant 消息，同样需要带 reasoning_content（thinking mode）
                String reasoning = m.getReasoningContent();
                if (reasoning != null && !reasoning.isEmpty()) {
                    out.add(LobsterMessage.assistantWithReasoning(content, reasoning));
                } else {
                    out.add(LobsterMessage.assistant(content));
                }
            } else {
                out.add(LobsterMessage.system(content));
            }
        }
        return out;
    }

    /**
     * 从 ThreadMessage.attachmentsJson 还原图片 dataUrl 列表 / Restore image dataUrls
     * from persisted attachmentsJson.
     *
     * <p>JSON 格式：{@code { "imageMediaIds": ["res_xxx", "res_yyy"] }}
     *
     * <p>逐个 resourceId：
     * <ol>
     *   <li>查 WorkspaceResource，校验 sourceType=USER_UPLOAD + mimeType=image/*</li>
     *   <li>从 metadata 拿 origRef → contentStore.readBinary 拿原始图片字节</li>
     *   <li>Base64 编码 → 拼成 {@code data:<mime>;base64,<b64>} dataUrl</li>
     * </ol>
     * 失败的条目（资源没了 / 不是图片 / 读盘失败）静默跳过——多模态历史不应阻塞主对话.
     */
    @SuppressWarnings("unchecked")
    private List<String> resolveImageDataUrlsFromAttachmentsJson(String attachmentsJson) {
        if (attachmentsJson == null || attachmentsJson.isEmpty()) return Collections.emptyList();
        List<String> out = new ArrayList<>();
        try {
            Object parsed = JsonUtil.fromJson(attachmentsJson, Map.class);
            if (!(parsed instanceof Map)) return out;
            Object ids = ((Map<String, Object>) parsed).get("imageMediaIds");
            if (!(ids instanceof List)) return out;
            for (Object idObj : (List<Object>) ids) {
                if (idObj == null) continue;
                String resourceId = String.valueOf(idObj);
                String dataUrl = readImageAsDataUrl(resourceId);
                if (dataUrl != null) out.add(dataUrl);
            }
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] parse attachmentsJson failed: " + attachmentsJson, t); }
            catch (Throwable ignore) { /* ignore */ }
        }
        return out;
    }

    /**
     * 进程级 LRU：resourceId → base64 dataUrl. 多模态历史每轮都要重读图片，
     * 加这层缓存能省掉绝大部分磁盘 I/O 和 base64 编码消耗.
     *
     * <p>USER_UPLOAD 图片不可覆盖（每次新上传 → 新 resourceId），所以缓存条目永不过期，
     * 也不需要主动失效；只靠 LRU 容量自然驱逐.
     *
     * <p>容量 50：典型 1568px 图片 base64 ≈ 300 KB，50 条 ≈ 15 MB 内存上限.
     * synchronizedMap 包 LinkedHashMap(accessOrder=true) 即得线程安全的 LRU.
     */
    private static final int IMAGE_DATA_URL_LRU_MAX = 50;
    private static final Map<String, String> IMAGE_DATA_URL_CACHE = Collections.synchronizedMap(
            new LinkedHashMap<String, String>(IMAGE_DATA_URL_LRU_MAX + 1, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                    return size() > IMAGE_DATA_URL_LRU_MAX;
                }
            });

    /** 单个图片 resourceId → base64 dataUrl；失败返 null. 走进程级 LRU. */
    private String readImageAsDataUrl(String resourceId) {
        if (resourceId == null || resourceId.isEmpty()) return null;
        String cached = IMAGE_DATA_URL_CACHE.get(resourceId);
        if (cached != null) return cached;
        try {
            WorkspaceResource r = workspaceService.getResource(resourceId);
            if (r == null) return null;
            if (r.getSourceType() != ResourceSourceType.USER_UPLOAD) return null;
            String mime = r.getMimeType();
            if (mime == null || !mime.startsWith("image/")) return null;
            Map<String, Object> meta = ResourceMetadata.readMap(r.getMetadataJson());
            Object origRefObj = meta == null ? null : meta.get("origRef");
            String origRef = origRefObj == null ? null : String.valueOf(origRefObj);
            if (origRef == null || origRef.isEmpty()) return null;
            byte[] bytes = contentStore.readBinary(origRef);
            if (bytes == null || bytes.length == 0) return null;
            String b64 = Base64.getEncoder().encodeToString(bytes);
            String dataUrl = "data:" + mime + ";base64," + b64;
            IMAGE_DATA_URL_CACHE.put(resourceId, dataUrl);
            return dataUrl;
        } catch (Throwable t) {
            try { Tools.log("[ContextAssembler] readImageAsDataUrl failed for " + resourceId, t); }
            catch (Throwable ignore) { /* ignore */ }
            return null;
        }
    }

    @SuppressWarnings("unchecked")
    private List<ToolCall> parseToolCalls(String json) {
        List<ToolCall> list = new ArrayList<>();
        try {
            Object o = JsonUtil.fromJson(json, List.class);
            if (o instanceof List) {
                for (Object item : (List<Object>) o) {
                    if (item instanceof Map) {
                        Map<String, Object> m = (Map<String, Object>) item;
                        String id = String.valueOf(m.get("id"));
                        String name = String.valueOf(m.get("name"));
                        String args = m.get("arguments") == null ? "{}" : String.valueOf(m.get("arguments"));
                        list.add(new ToolCall(id, name, args));
                    }
                }
            }
        } catch (Throwable ignore) { /* keep empty */ }
        return list;
    }

    private int findFirstTranscriptIndex(List<LobsterMessage> list) {
        for (int i = 0; i < list.size(); i++) {
            MessageRole r = list.get(i).getRole();
            if (r == MessageRole.user || r == MessageRole.assistant || r == MessageRole.tool) return i;
        }
        return -1;
    }
}
