package com.agent.management.controller;

import com.agent.management.common.Result;
import com.agent.management.engine.hermes.HermesBridgeClient;
import com.agent.management.external.SseEventBus;
import com.agent.management.external.SseHeartbeats;
import com.agent.management.model.entity.ChatMessage;
import com.agent.management.model.entity.ChatMessageLog;
import com.agent.management.model.entity.ChatSession;
import com.agent.management.model.entity.WorkflowPause;
import com.agent.management.repository.ChatMessageLogRepository;
import com.agent.management.repository.ChatMessageRepository;
import com.agent.management.repository.ChatSessionRepository;
import com.agent.management.repository.WorkflowPauseRepository;
import com.agent.management.service.ChatMessageLogService;
import com.agent.management.service.ChatMessageService;
import com.agent.management.service.ChatRunService;
import com.agent.management.service.ChatSessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * Agent 对话页面 REST Controller。
 *
 * <p>对应设计文档 {@code docs/design/agent-chat-page-design.md} §4.2。
 * 与 {@code WorkflowController} 平级，路径前缀 {@code /api/chat/...}。</p>
 *
 * <p>本类只放"阶段 1 核心 endpoints"：会话列表/详情、发起 run、SSE 订阅、
 * 状态查询、提交 clarify 答案。版本管理（重新生成/编辑）、重置、导出、消息分页
 * 留待后续阶段（参见 todo）。</p>
 */
@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {

    private final ChatSessionService sessionService;
    private final ChatMessageService messageService;
    private final ChatMessageLogService logService;
    private final ChatRunService runService;
    private final ChatSessionRepository sessionRepo;
    private final ChatMessageRepository messageRepo;
    private final ChatMessageLogRepository logRepo;
    private final WorkflowPauseRepository pauseRepo;
    private final SseEventBus sseEventBus;
    private final HermesBridgeClient hermesBridgeClient;

    /* ============== 会话管理 ============== */

    /** 列出所有 ACTIVE 会话，按 last_message_at 倒序。 */
    @GetMapping("/sessions")
    public Result<List<Map<String, Object>>> listSessions() {
        List<ChatSession> sessions = sessionService.listActive();
        List<Map<String, Object>> result = new ArrayList<>(sessions.size());
        for (ChatSession s : sessions) {
            Map<String, Object> dto = toSessionDto(s);
            dto.put("hasActiveRun", hasActiveRun(s.getId()));
            result.add(dto);
        }
        return Result.success(result);
    }

    /** 新建会话。Body: {title?, modelId?, skillFolder?} */
    @PostMapping("/sessions")
    public Result<Map<String, Object>> createSession(@RequestBody Map<String, Object> body) {
        String title = body.get("title") != null ? String.valueOf(body.get("title")) : null;
        if (title != null && title.length() > 255) {
            return Result.error("标题不能超过 255 字符");
        }
        Long modelId;
        try {
            modelId = parseLong(body.get("modelId"));
        } catch (NumberFormatException e) {
            return Result.error("modelId 必须是数字");
        }
        String skillFolder = body.get("skillFolder") != null ? String.valueOf(body.get("skillFolder")) : null;
        ChatSession s = sessionService.create(title, modelId, skillFolder);
        return Result.success(Map.of("sessionId", s.getId(), "sessionUuid", s.getSessionUuid()));
    }

    /**
     * 修改会话属性：重命名 / 置顶。Body: {title?, pinned?}，仅传哪个字段就只改哪个。
     * <ul>
     *   <li>title：1..255 字符，trim 后非空</li>
     *   <li>pinned：true/false</li>
     * </ul>
     */
    @PatchMapping("/sessions/{id}")
    public Result<Map<String, Object>> updateSession(@PathVariable Long id,
                                                      @RequestBody Map<String, Object> body) {
        if (sessionService.findById(id).isEmpty()) {
            return Result.error("会话不存在: " + id);
        }
        boolean changed = false;
        if (body.containsKey("title")) {
            String title = body.get("title") != null ? String.valueOf(body.get("title")).trim() : null;
            if (title == null || title.isEmpty()) {
                return Result.error("标题不能为空");
            }
            if (title.length() > 255) {
                return Result.error("标题不能超过 255 字符");
            }
            sessionService.updateTitle(id, title);
            changed = true;
        }
        if (body.containsKey("pinned")) {
            Object v = body.get("pinned");
            boolean pinned;
            if (v instanceof Boolean) {
                pinned = (Boolean) v;
            } else {
                pinned = Boolean.parseBoolean(String.valueOf(v));
            }
            sessionService.setPinned(id, pinned);
            changed = true;
        }
        if (!changed) {
            return Result.error("未指定要修改的字段（title / pinned）");
        }
        return Result.success(toSessionDto(sessionService.findById(id).get()));
    }

    /** 获取会话详情 + 当前激活分支所有消息（含日志）。 */
    @GetMapping("/sessions/{id}")
    public Result<Map<String, Object>> getSession(@PathVariable Long id) {
        Optional<ChatSession> sessionOpt = sessionService.findById(id);
        if (sessionOpt.isEmpty()) {
            return Result.error("会话不存在: " + id);
        }
        Map<String, Object> result = new LinkedHashMap<>();
        result.put("session", toSessionDto(sessionOpt.get()));
        List<Map<String, Object>> messages = new ArrayList<>();
        for (ChatMessage m : messageService.getActiveBranch(id)) {
            messages.add(toMessageDto(m));
        }
        result.put("messages", messages);
        return Result.success(result);
    }

    /* ============== 单轮问答（核心） ============== */

    /**
     * 发起一轮问答。Body: {message, modelId?, skillFolder?, maxIterations?, parentMessageId?}
     * 返回 {runId, messageId}（messageId 为新建的 assistant 占位消息 id）。
     */
    @PostMapping("/sessions/{sessionId}/runs")
    public Result<Map<String, Object>> startRun(@PathVariable Long sessionId,
                                                  @RequestBody Map<String, Object> body) {
        if (!sessionService.findById(sessionId).isPresent()) {
            return Result.error("会话不存在: " + sessionId);
        }
        String message = body.get("message") != null ? String.valueOf(body.get("message")) : null;
        if (message == null || message.isBlank()) {
            return Result.error("消息内容不能为空");
        }
        if (message.length() > 20_000) {
            return Result.error("单条消息不能超过 20000 字符");
        }
        Long modelId;
        Integer maxIterations;
        Long parentMessageId;
        try {
            modelId = parseLong(body.get("modelId"));
            maxIterations = parseInt(body.get("maxIterations"));
            parentMessageId = parseLong(body.get("parentMessageId"));
        } catch (NumberFormatException e) {
            return Result.error(e.getMessage());
        }
        String skillFolder = body.get("skillFolder") != null ? String.valueOf(body.get("skillFolder")) : null;
        if (skillFolder != null && skillFolder.length() > 255) {
            return Result.error("技能目录名非法");
        }
        if (maxIterations != null && (maxIterations < 1 || maxIterations > 1000)) {
            return Result.error("maxIterations 必须在 1..1000 之间");
        }

        try {
            ChatRunService.RunStartResult r = runService.startRun(
                    sessionId, message, modelId, skillFolder, maxIterations, parentMessageId);
            return Result.success(Map.of("runId", r.runId(), "messageId", r.assistantMessageId()));
        } catch (IllegalArgumentException | IllegalStateException e) {
            // 业务校验失败：消息可读，原样返回
            return Result.error(e.getMessage());
        } catch (Exception e) {
            // 系统异常：不向前端暴露内部细节，仅写日志
            log.error("[ChatController] startRun 失败 sessionId={}", sessionId, e);
            return Result.error("启动失败，请稍后重试");
        }
    }

    /**
     * SSE 订阅（断线重连）。复用 {@link SseEventBus#subscribe}。
     * 支持 Last-Event-ID 头部或 query 参数。
     */
    @GetMapping(value = "/runs/{runId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter reconnectStream(@PathVariable String runId,
                                       @RequestHeader(value = "Last-Event-ID", required = false) Long lastEventIdHeader,
                                       @RequestParam(value = "lastEventId", required = false) Long lastEventIdQuery) {
        Long lastEventId = lastEventIdHeader != null ? lastEventIdHeader : lastEventIdQuery;
        SseEmitter emitter = sseEventBus.subscribe(runId, lastEventId);
        if (emitter == null) {
            // run 不存在或已过期：返回短时 emitter 报错并立即完成。
            // 前端 useGlobalRuns 自带 5 次/3s 退避重试，重连耗尽后会降级为 GET /state 查询。
            emitter = new SseEmitter(60_000L);
            try {
                emitter.send(SseEmitter.event()
                        .name("workflow_error")
                        .data(Map.of("error", "运行不存在或已过期: " + runId))
                        .comment(":retry-after:10000"));
                emitter.complete();
            } catch (IOException ignore) { /* ignore */ }
            return emitter;
        }
        SseHeartbeats.start(emitter);
        return emitter;
    }

    /**
     * 查询运行状态 + 当前未答暂停点。
     * 用户离开几小时后回来时先调此端点：若 status=RUNNING 且 currentPause 非空，
     * 立即恢复 Askfor UI；然后再调 /stream 续接 SSE。
     */
    @GetMapping("/runs/{runId}/state")
    public Result<Map<String, Object>> getRunState(@PathVariable String runId) {
        Map<String, Object> state = new LinkedHashMap<>();
        // 通过 runId 查询对应的消息（ChatMessage.runId 唯一对应一轮 run）
        Optional<ChatMessage> msgOpt = messageRepo.findByRunId(runId);
        if (msgOpt.isPresent()) {
            state.put("status", msgOpt.get().getStatus());
            state.put("messageId", msgOpt.get().getId());
            state.put("sessionId", msgOpt.get().getSessionId());
        } else {
            state.put("status", "UNKNOWN");
        }
        // 当前未答暂停点
        Map<String, Object> pause = null;
        try {
            Optional<WorkflowPause> wp = pauseRepo.findFirstByRunIdAndStatusOrderByCreatedAtDesc(
                    runId, "UNANSWERED");
            if (wp.isPresent()) {
                pause = new LinkedHashMap<>();
                pause.put("clarifyId", wp.get().getClarifyId());
                pause.put("nodeId", wp.get().getNodeId());
                pause.put("question", wp.get().getQuestion());
                pause.put("choices", wp.get().getChoices());
                pause.put("createdAt", wp.get().getCreatedAt());
            }
        } catch (Exception e) {
            log.warn("[ChatController] 查询 WorkflowPause 失败 runId={}: {}", runId, e.getMessage());
        }
        state.put("currentPause", pause);
        return Result.success(state);
    }

    /**
     * 提交 clarify 答案，唤醒 Bridge 中阻塞的 Hermes Agent 线程。
     * Body: {clarifyId, answer}
     */
    @PostMapping("/runs/{runId}/resume")
    public Result<Map<String, Object>> resumeRun(@PathVariable String runId, @RequestBody Map<String, String> body) {
        String clarifyId = body.get("clarifyId");
        String answer = body.get("answer");
        if (clarifyId == null || clarifyId.isBlank()) {
            return Result.error("缺少 clarifyId");
        }
        if (answer != null && answer.length() > 5000) {
            return Result.error("答案不能超过 5000 字符");
        }
        // 更新 WorkflowPause 状态
        try {
            pauseRepo.findByClarifyId(clarifyId).ifPresent(p -> {
                p.setAnswer(answer);
                p.setAnsweredAt(LocalDateTime.now());
                p.setStatus("ANSWERED");
                pauseRepo.save(p);
            });
        } catch (Exception e) {
            log.warn("[ChatController] 更新 WorkflowPause 失败 clarifyId={}: {}", clarifyId, e.getMessage());
        }
        // 转发给 Bridge，唤醒阻塞线程
        boolean resolved = false;
        try {
            resolved = hermesBridgeClient.submitClarifyAnswer(clarifyId, answer);
        } catch (Exception e) {
            log.warn("[ChatController] 调用 Bridge submitClarifyAnswer 失败 clarifyId={}: {}",
                    clarifyId, e.getMessage());
        }
        return Result.success(Map.of("resolved", resolved));
    }

    /* ============== 会话级操作（A 删除 / 导出 / D 重置） ============== */

    /** 删除会话（软删除：标 ARCHIVED，消息树保留供审计）。 */
    @DeleteMapping("/sessions/{id}")
    public Result<Map<String, Object>> deleteSession(@PathVariable Long id) {
        if (sessionService.findById(id).isEmpty()) {
            return Result.error("会话不存在: " + id);
        }
        sessionService.archive(id);
        return Result.success(Map.of("id", id, "status", "ARCHIVED"));
    }

    /**
     * 导出会话为 Markdown 文档。
     * 文件名：`对话-{标题或id}-{yyyyMMdd-HHmmss}.md`，UTF-8 编码。
     * 仅导出当前激活分支。
     */
    @GetMapping("/sessions/{id}/export")
    public ResponseEntity<byte[]> exportSession(@PathVariable Long id) {
        Optional<ChatSession> sessionOpt = sessionService.findById(id);
        if (sessionOpt.isEmpty()) {
            return ResponseEntity.notFound().build();
        }
        ChatSession session = sessionOpt.get();
        List<ChatMessage> messages = messageService.getActiveBranch(id);

        StringBuilder sb = new StringBuilder();
        sb.append("# ").append(session.getTitle() != null ? session.getTitle() : "对话 " + id).append("\n\n");
        sb.append("- 会话ID: ").append(id).append("\n");
        sb.append("- 创建时间: ").append(formatTime(session.getCreatedAt())).append("\n");
        sb.append("- 最后消息: ").append(formatTime(session.getLastMessageAt())).append("\n\n");
        sb.append("---\n\n");

        DateTimeFormatter timeFmt = DateTimeFormatter.ofPattern("HH:mm:ss");
        for (ChatMessage m : messages) {
            String role = "USER".equals(m.getRole()) ? "🙋 用户" : "🤖 助手";
            String time = m.getCreatedAt() != null ? m.getCreatedAt().format(timeFmt) : "--:--:--";
            sb.append("## ").append(role).append("  ·  ").append(time).append("\n\n");
            if (m.getContent() != null && !m.getContent().isBlank()) {
                sb.append(m.getContent()).append("\n\n");
            }
            if ("FAILED".equals(m.getStatus()) && m.getError() != null) {
                sb.append("> ❌ 失败：").append(m.getError()).append("\n\n");
            }
            // 助手消息附带"思考过程"日志（仅 thinking 类型）
            if ("ASSISTANT".equals(m.getRole())) {
                List<ChatMessageLog> logs = logRepo.findByMessageIdAndKindOrderBySeqAsc(m.getId(), "thinking");
                if (!logs.isEmpty()) {
                    sb.append("<details><summary>思考过程（").append(logs.size()).append(" 段）</summary>\n\n");
                    for (ChatMessageLog l : logs) {
                        if (l.getContent() != null && !l.getContent().isBlank()) {
                            sb.append("> ").append(l.getContent().replace("\n", "\n> ")).append("\n\n");
                        }
                    }
                    sb.append("</details>\n\n");
                }
            }
        }

        byte[] bytes = sb.toString().getBytes(StandardCharsets.UTF_8);
        String safeTitle = session.getTitle() != null ? session.getTitle() : String.valueOf(id);
        String filename = "对话-" + safeTitle.replaceAll("[\\\\/:*?\"<>|]", "_") + "-"
                + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".md";
        String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(new MediaType("text", "markdown", StandardCharsets.UTF_8));
        headers.set(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename);
        headers.setContentLength(bytes.length);
        return new ResponseEntity<>(bytes, headers, org.springframework.http.HttpStatus.OK);
    }

    /**
     * 重置会话：把所有消息置 inactive（软清空，不删数据）。
     * 下次用户发消息时视为新对话起点。
     * 注：Bridge 端的 Agent 缓存按 system_prompt 索引，本轮不动；
     *     若 Hermes 仍保留旧对话上下文，可在 Bridge 端补 reset 端点（待后续阶段）。
     */
    @PostMapping("/sessions/{id}/reset")
    public Result<Map<String, Object>> resetSession(@PathVariable Long id) {
        if (sessionService.findById(id).isEmpty()) {
            return Result.error("会话不存在: " + id);
        }
        int affected = messageService.deactivateAllInSession(id);
        return Result.success(Map.of("id", id, "deactivatedMessages", affected));
    }

    /* ============== 版本管理（B 重新生成 / 编辑 / 切换） ============== */

    /**
     * 重新生成 assistant 消息：旧 assistant 置 inactive → 新建同 parent 的 assistant 占位消息 → 启动 run。
     * 注意：parent（即对应的 user 消息）保持不变。
     */
    @PostMapping("/messages/{messageId}/regenerate")
    public Result<Map<String, Object>> regenerateMessage(@PathVariable Long messageId) {
        Optional<ChatMessage> oldOpt = messageRepo.findById(messageId);
        if (oldOpt.isEmpty()) {
            return Result.error("消息不存在: " + messageId);
        }
        ChatMessage old = oldOpt.get();
        if (!"ASSISTANT".equals(old.getRole())) {
            return Result.error("仅 ASSISTANT 消息可重新生成");
        }
        if ("RUNNING".equals(old.getStatus())) {
            return Result.error("该消息仍在运行，无法重新生成");
        }
        Long parentUserId = old.getParentId();
        if (parentUserId == null) {
            return Result.error("无法定位父用户消息");
        }
        Optional<ChatMessage> parentOpt = messageRepo.findById(parentUserId);
        if (parentOpt.isEmpty() || !"USER".equals(parentOpt.get().getRole())) {
            return Result.error("父消息不是 USER，无法重新生成");
        }
        String userMessage = parentOpt.get().getContent();
        try {
            // 1. 把同 parent 的旧 ASSISTANT 兄弟置 inactive（含 old）
            messageService.deactivateSiblings(parentUserId);
            // 2. startRun：以 parentUserId 作 parentMessageId，
            //    startRun 检测 parentIsUser=true 会复用该 USER 消息、新建 ASSISTANT 占位并触发 run
            ChatRunService.RunStartResult r = runService.startRun(
                    old.getSessionId(), userMessage, old.getModelId(), old.getSkillFolder(),
                    null, parentUserId);
            return Result.success(Map.of("runId", r.runId(), "messageId", r.assistantMessageId()));
        } catch (IllegalArgumentException | IllegalStateException e) {
            return Result.error(e.getMessage());
        } catch (Exception e) {
            log.error("[ChatController] regenerateMessage 失败 messageId={}", messageId, e);
            return Result.error("重新生成失败，请稍后重试");
        }
    }

    /**
     * 编辑 user 消息：旧 user 置 inactive → 新建同 parent 的新 user 消息 → 启动 run。
     * Body: {content}
     */
    @PatchMapping("/messages/{messageId}")
    public Result<Map<String, Object>> editMessage(@PathVariable Long messageId,
                                                     @RequestBody Map<String, Object> body) {
        Optional<ChatMessage> oldOpt = messageRepo.findById(messageId);
        if (oldOpt.isEmpty()) {
            return Result.error("消息不存在: " + messageId);
        }
        ChatMessage old = oldOpt.get();
        if (!"USER".equals(old.getRole())) {
            return Result.error("仅 USER 消息可编辑");
        }
        String newContent = body.get("content") != null ? String.valueOf(body.get("content")) : null;
        if (newContent == null || newContent.isBlank()) {
            return Result.error("消息内容不能为空");
        }
        if (newContent.length() > 20_000) {
            return Result.error("单条消息不能超过 20000 字符");
        }
        try {
            // 编辑场景：editUserMessage 已创建新的 USER 消息并返回其 id
            Long freshUserMsgId = messageService.editUserMessage(messageId, newContent);
            // 把 freshUserMsgId 作为 parentMessageId 传入：ChatRunService.startRun 会检测到
            // parentIsUser=true 分支，复用该 USER 消息（不再新建），仅创建新的 ASSISTANT 占位并触发 run。
            // 这样可以避免重复创建 USER 消息，且新旧消息通过 parent_id 链形成版本树。
            ChatRunService.RunStartResult r = runService.startRun(
                    old.getSessionId(), newContent, old.getModelId(), old.getSkillFolder(),
                    null, freshUserMsgId);
            return Result.success(Map.of("runId", r.runId(), "messageId", r.assistantMessageId()));
        } catch (IllegalArgumentException | IllegalStateException e) {
            return Result.error(e.getMessage());
        } catch (Exception e) {
            log.error("[ChatController] editMessage 失败 messageId={}", messageId, e);
            return Result.error("编辑失败，请稍后重试");
        }
    }

    /**
     * 列出某消息的同 parent 兄弟（即"版本树"上的所有版本）。
     * 返回前端用于渲染版本切换器 `< 2/3 >`。
     */
    @GetMapping("/messages/{messageId}/versions")
    public Result<Map<String, Object>> listVersions(@PathVariable Long messageId) {
        Optional<ChatMessage> opt = messageRepo.findById(messageId);
        if (opt.isEmpty()) {
            return Result.error("消息不存在: " + messageId);
        }
        ChatMessage current = opt.get();
        if (current.getParentId() == null) {
            // 首条消息没有兄弟
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("versions", List.of());
            data.put("currentIndex", -1);
            data.put("parentId", null);
            return Result.success(data);
        }
        List<ChatMessage> siblings = messageRepo.findByParentIdOrderByBranchOrderAsc(current.getParentId());
        List<Map<String, Object>> versions = new ArrayList<>(siblings.size());
        int currentIdx = -1;
        for (int i = 0; i < siblings.size(); i++) {
            ChatMessage s = siblings.get(i);
            Map<String, Object> v = new LinkedHashMap<>();
            v.put("id", s.getId());
            v.put("branchOrder", s.getBranchOrder());
            v.put("isActive", s.getIsActive());
            v.put("role", s.getRole());
            v.put("preview", truncate(s.getContent(), 60));
            v.put("status", s.getStatus());
            v.put("createdAt", s.getCreatedAt());
            versions.add(v);
            if (s.getId().equals(messageId)) {
                currentIdx = i;
            }
        }
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("versions", versions);
        data.put("currentIndex", currentIdx);
        data.put("parentId", current.getParentId());
        return Result.success(data);
    }

    /**
     * 切换激活版本：把同 parent 的所有消息置 inactive，目标 messageId 置 active。
     * 仅适用于"已是历史"的消息；RUNNING 中的消息不能被切换。
     */
    @PostMapping("/messages/{messageId}/activate")
    public Result<Map<String, Object>> activateVersion(@PathVariable Long messageId) {
        Optional<ChatMessage> opt = messageRepo.findById(messageId);
        if (opt.isEmpty()) {
            return Result.error("消息不存在: " + messageId);
        }
        if ("RUNNING".equals(opt.get().getStatus())) {
            return Result.error("RUNNING 中的消息不能切换");
        }
        try {
            messageService.activateVersion(messageId);
            return Result.success(Map.of("messageId", messageId, "activated", true));
        } catch (IllegalArgumentException e) {
            return Result.error(e.getMessage());
        }
    }

    /* ============== 内部工具 ============== */

    private Map<String, Object> toSessionDto(ChatSession s) {
        Map<String, Object> dto = new LinkedHashMap<>();
        dto.put("id", s.getId());
        dto.put("sessionUuid", s.getSessionUuid());
        dto.put("title", s.getTitle());
        dto.put("modelId", s.getModelId());
        dto.put("skillFolder", s.getSkillFolder());
        dto.put("status", s.getStatus());
        dto.put("pinned", Boolean.TRUE.equals(s.getPinned()));
        dto.put("hermesSession", s.getHermesSession());
        dto.put("lastMessageAt", s.getLastMessageAt());
        dto.put("createdAt", s.getCreatedAt());
        return dto;
    }

    /** 把消息+其日志行组装为前端可渲染的 DTO */
    private Map<String, Object> toMessageDto(ChatMessage m) {
        Map<String, Object> dto = new LinkedHashMap<>();
        dto.put("id", m.getId());
        dto.put("parentId", m.getParentId());
        dto.put("role", m.getRole());
        dto.put("content", m.getContent());
        dto.put("status", m.getStatus());
        dto.put("runId", m.getRunId());
        dto.put("branchOrder", m.getBranchOrder());
        dto.put("isActive", m.getIsActive());
        dto.put("modelId", m.getModelId());
        dto.put("skillFolder", m.getSkillFolder());
        dto.put("error", m.getError());
        dto.put("createdAt", m.getCreatedAt());
        dto.put("completedAt", m.getCompletedAt());
        // ASSISTANT 消息附带日志行（按 seq 排序）
        if ("ASSISTANT".equals(m.getRole())) {
            List<ChatMessageLog> logs = logService.findByMessage(m.getId());
            dto.put("logs", logs);
        }
        return dto;
    }

    /** 会话是否有进行中的 run：查是否存在 RUNNING 状态的激活 ASSISTANT 消息 */
    private boolean hasActiveRun(Long sessionId) {
        return messageRepo.findFirstBySessionIdAndRoleAndStatusAndIsActiveTrueOrderByCreatedAtDesc(
                sessionId, "ASSISTANT", "RUNNING").isPresent();
    }

    /**
     * 解析 Long；解析失败抛出带字段名的 {@link NumberFormatException}，
     * 上层 catch 后转 {@link Result#error}。
     */
    private static Long parseLong(Object v) throws NumberFormatException {
        if (v == null) return null;
        if (v instanceof Number) return ((Number) v).longValue();
        try {
            return Long.valueOf(String.valueOf(v));
        } catch (NumberFormatException e) {
            throw new NumberFormatException("数字字段格式非法: " + v);
        }
    }

    private static Integer parseInt(Object v) throws NumberFormatException {
        if (v == null) return null;
        if (v instanceof Number) return ((Number) v).intValue();
        try {
            return Integer.valueOf(String.valueOf(v));
        } catch (NumberFormatException e) {
            throw new NumberFormatException("数字字段格式非法: " + v);
        }
    }

    /** LocalDateTime → 字符串（导出 Markdown 用，null 返回 "-"） */
    private static String formatTime(LocalDateTime t) {
        if (t == null) return "-";
        return t.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    }

    /** 字符串截断（版本预览用）；超长加 "…" */
    private static String truncate(String s, int max) {
        if (s == null) return "";
        String collapsed = s.replaceAll("\\s+", " ").trim();
        return collapsed.length() > max ? collapsed.substring(0, max) + "…" : collapsed;
    }
}
