package com.agent.management.controller;

import com.agent.management.common.Result;
import com.agent.management.engine.AgentIgnoreFilter;
import com.agent.management.engine.WorkflowEngine;
import com.agent.management.engine.WorkflowRunDirManager;
import com.agent.management.engine.WorkflowRunRequest;
import com.agent.management.engine.hermes.HermesBridgeClient;
import com.agent.management.external.SseEventBus;
import com.agent.management.external.SseHeartbeats;
import com.agent.management.model.dto.SkillDTO;
import com.agent.management.model.entity.Workflow;
import com.agent.management.model.entity.WorkflowPause;
import com.agent.management.model.entity.WorkflowRun;
import com.agent.management.model.vo.PageResult;
import com.agent.management.model.vo.WorkflowVersionVO;
import com.agent.management.model.vo.WorkflowVO;
import com.agent.management.repository.WorkflowPauseRepository;
import com.agent.management.repository.WorkflowRunRepository;
import com.agent.management.repository.WfCategoryRepository;
import com.agent.management.service.AiModelService;
import com.agent.management.service.SkillService;
import com.agent.management.service.TagService;
import com.agent.management.service.WfCategoryService;
import com.agent.management.service.WorkflowDescriptionService;
import com.agent.management.service.WorkflowService;
import com.agent.management.service.impl.WorkflowVersionServiceImpl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

@Slf4j
@RestController
@RequestMapping("/api/workflows")
@RequiredArgsConstructor
public class WorkflowController {

    private final WorkflowService workflowService;
    private final WorkflowEngine workflowEngine;
    private final WorkflowRunDirManager runDirManager;
    private final TagService tagService;
    private final WfCategoryRepository wfCategoryRepository;
    private final WfCategoryService wfCategoryService;
    private final SkillService skillService;
    private final AiModelService aiModelService;
    private final WorkflowVersionServiceImpl workflowVersionService;
    private final WorkflowDescriptionService workflowDescriptionService;
    private final AgentIgnoreFilter agentIgnoreFilter;
    /** v1.1：断线重连入口（GET /runs/{runId}/stream 复用现有 EventBus） */
    private final SseEventBus sseEventBus;
    /** v1.1：clarify 持久化查询（GET /state）与回答更新（POST /resume） */
    private final WorkflowPauseRepository workflowPauseRepository;
    /** v1.1：通过 runId 反查 workflowId / 状态（GET /state 用） */
    private final WorkflowRunRepository workflowRunRepository;
    /** v1.1：调用 Bridge 的 /clarify/{id}/answer 端点（hermes.enabled=false 时为 null） */
    @Autowired(required = false)
    private HermesBridgeClient hermesBridgeClient;

    private static final ObjectMapper MAPPER = new ObjectMapper();

    /** 轻量级输入防御：runId / clarifyId 只允许字母、数字、下划线、连字符，长度 1-128。
     *  内部 API 无统一鉴权过滤器，至少阻断路径穿越、SQL 注入、XSS 等明显恶意输入。 */
    private static final java.util.regex.Pattern ID_PATTERN =
            java.util.regex.Pattern.compile("^[a-zA-Z0-9_-]{1,128}$");

    /** 暂停点时间戳固定使用 ISO_LOCAL_DATE_TIME（带毫秒），避免 LocalDateTime.toString() 因纳秒为零时省略小数部分导致前端解析不一致。 */
    private static final java.time.format.DateTimeFormatter PAUSE_TIME_FMT =
            java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;

    private static boolean isValidId(String id) {
        return id != null && ID_PATTERN.matcher(id).matches();
    }

    @GetMapping
    public Result<PageResult<WorkflowVO>> list(
            @RequestParam(required = false) String search,
            @RequestParam(required = false) Long categoryId,
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "12") int size) {

        List<Workflow> all = workflowService.listWorkflows();

        // 搜索过滤（匹配 name / displayName / description）
        if (search != null && !search.trim().isEmpty()) {
            String kw = search.trim().toLowerCase();
            all = all.stream()
                    .filter(wf -> (wf.getName() != null && wf.getName().toLowerCase().contains(kw))
                            || (wf.getDisplayName() != null && wf.getDisplayName().toLowerCase().contains(kw))
                            || (wf.getDescription() != null && wf.getDescription().toLowerCase().contains(kw)))
                    .collect(Collectors.toList());
        }

        // 分类过滤（含子孙分类）
        if (categoryId != null) {
            List<Long> catIds = new ArrayList<>();
            catIds.add(categoryId);
            catIds.addAll(wfCategoryService.getDescendantCategoryIds(categoryId));
            all = all.stream()
                    .filter(wf -> wf.getCategoryId() != null && catIds.contains(wf.getCategoryId()))
                    .collect(Collectors.toList());
        }

        // 边界保护：前端可能传 page=0 / 负数 / 超界值，避免 subList 抛 IndexOutOfBoundsException
        if (page < 1) page = 1;
        if (size < 1) size = 12;

        int total = all.size();
        int totalPages = Math.max(1, (int) Math.ceil((double) total / size));
        if (page > totalPages) page = totalPages;
        int from = (page - 1) * size;
        int to = Math.min(from + size, total);
        // from 必须 >= 0 且 < total 才能调 subList；否则（空列表、越界）返回空
        List<Workflow> pageData = (from >= 0 && from < total) ? all.subList(from, to) : List.of();

        List<WorkflowVO> voList = pageData.stream().map(this::toVO).collect(Collectors.toList());
        return Result.success(PageResult.of(voList, total, page, size));
    }

    @GetMapping("/{name}")
    public Result<WorkflowVO> get(@PathVariable String name) {
        Workflow wf = workflowService.getWorkflow(name);
        return Result.success(toVO(wf));
    }

    @PostMapping
    public Result<WorkflowVO> create(@RequestBody CreateWorkflowReq req) {
        Workflow wf = workflowService.createWorkflow(req.getName(), req.getDisplayName(), req.getDescription(), req.getMode());
        return Result.success(toVO(wf));
    }

    @PostMapping("/{name}/save")
    public Result<WorkflowVO> save(@PathVariable String name, @RequestBody SaveWorkflowReq req) {
        Workflow wf = workflowService.updateWorkflow(name, req.getName(), req.getDisplayName(),
                req.getDescription(), req.getGraphData(), req.getMode());
        // 自动创建版本快照（双轨目录：优先 name，回退 id）
        try {
            workflowVersionService.createSnapshot(wf.getId(), wf.getName(), req.getGraphData(), req.getName(), null);
        } catch (Exception e) {
            log.warn("[Save] 创建版本快照失败，不影响保存: {}", e.getMessage());
        }
        return Result.success(toVO(wf));
    }

    @PostMapping("/{name}/delete")
    public Result<Void> delete(@PathVariable String name) {
        workflowService.deleteWorkflow(name);
        return Result.success(null);
    }

    /**
     * AI 生成工作流描述。
     *
     * <p>先由 LLM 判断当前描述是否已说清楚「作用 + 使用场景」：
     * 已清晰则返回 skipped=true（调用方不应修改描述）；
     * 否则根据节点配置生成新描述返回。本接口只生成、不保存，
     * 由调用方决定是否再调 save 写回。
     */
    @PostMapping("/{name}/generate-description")
    public Result<GenerateDescResp> generateDescription(@PathVariable String name) {
        WorkflowDescriptionService.GenerateResult r = workflowDescriptionService.generateDescription(name);
        return Result.success(new GenerateDescResp(r.skipped(), r.reason(), r.description()));
    }

    /**
     * 仅更新描述（不建版本快照）。
     *
     * <p>用于 AI 批量生成描述后的自动保存：save 接口会连带创建版本快照，
     * 批量场景会污染版本历史，故提供此轻量接口。
     */
    @PutMapping("/{name}/description")
    public Result<Void> updateDescription(@PathVariable String name, @RequestBody UpdateDescReq req) {
        workflowService.updateWorkflow(name, null, null, req.getDescription(), null, null);
        return Result.success(null);
    }

    /** 设置工作流分类 */
    @PutMapping("/{name}/category")
    public Result<Void> setCategory(@PathVariable String name, @RequestBody SetCategoryReq req) {
        workflowService.updateCategory(name, req.getCategoryId());
        return Result.success(null);
    }

    /**
     * 运行工作流（SSE 实时推送执行状态）
     * 支持 multipart：inputs 作为 JSON 字符串字段 + 可选文件上传
     */
    @PostMapping(value = "/{name}/run", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter run(@PathVariable String name,
                          @RequestParam(value = "inputs", required = false) String inputsJson,
                          @RequestParam(value = "files", required = false) MultipartFile[] files,
                          @RequestParam(value = "paths", required = false) List<String> paths) throws IOException {
        // 解析 inputs
        Map<String, Object> inputs = new HashMap<>();
        if (inputsJson != null && !inputsJson.isBlank()) {
            try {
                @SuppressWarnings("unchecked")
                Map<String, Object> parsed = MAPPER.readValue(inputsJson, Map.class);
                inputs.putAll(parsed);
            } catch (Exception e) {
                log.warn("[Run] 解析 inputs JSON 失败，使用空 inputs: {}", e.getMessage());
            }
        }

        // 解析工作流（按 name 查找，必要时回退 id）
        Workflow wf = workflowService.getWorkflow(name);
        Long workflowId = wf.getId();
        String wfName = wf.getName();

        // 如果有文件上传，先生成一个 runId 用于创建工作目录
        // 注意：实际 runId 由 WorkflowEngine 内部生成，但我们需要提前创建目录来存放文件
        // 所以这里提前生成 runId 并通过 inputs 传递给引擎
        if (files != null && files.length > 0) {
            String preRunId = UUID.randomUUID().toString().substring(0, 8);
            Path runDir = runDirManager.createRunDir(workflowId, wfName, preRunId);
            for (int i = 0; i < files.length; i++) {
                MultipartFile file = files[i];
                if (!file.isEmpty()) {
                    // 优先使用前端传递的相对路径，否则用原始文件名
                    String relativePath = (paths != null && i < paths.size() && !paths.get(i).isBlank())
                            ? paths.get(i) : file.getOriginalFilename();
                    // ZIP slip 防护：normalize 后必须仍在 runDir 内
                    Path dest = runDir.resolve(relativePath).normalize();
                    if (!dest.startsWith(runDir)) {
                        log.warn("[Run] 拒绝越界上传路径: {} (runId={})", relativePath, preRunId);
                        throw new IllegalArgumentException("非法上传路径: " + relativePath);
                    }
                    // 确保父目录存在（目录上传时子目录可能不存在）
                    Files.createDirectories(dest.getParent());
                    file.transferTo(dest.toFile());
                    // 将文件相对路径注册到 inputs 中
                    inputs.putIfAbsent("uploadedFiles", new ArrayList<String>());
                    @SuppressWarnings("unchecked")
                    List<String> uploadedFiles = (List<String>) inputs.get("uploadedFiles");
                    uploadedFiles.add(runDir.relativize(dest).toString().replace('\\', '/'));
                }
            }
            // 传递预生成的 runId 给引擎
            inputs.put("_preRunId", preRunId);
        }

        // v1.1：SseEmitter 超时从 30 分钟延长到 24 小时（兜底）。
        // Askfor 场景用户可能几小时后才回来回答 clarify，工作流线程一直阻塞。
        // 真正的"无超时"由前端断线重连机制（GET /runs/{runId}/stream?lastEventId=N）保证。
        SseEmitter emitter = new SseEmitter(86_400_000L); // 24 小时
        // v1.1：每 30 秒发送 SSE 心跳，防止 NAT/防火墙清连接
        SseHeartbeats.start(emitter);
        workflowEngine.executeAsync(workflowId, inputs, emitter);
        return emitter;
    }

    /**
     * v1.1：提交用户对 clarify_request 的回答。
     * 唤醒 Bridge 中阻塞等待的 Hermes Agent 线程，工作流继续执行。
     *
     * @param runId 工作流运行 ID
     * @param body  {clarifyId, answer}
     */
    @PostMapping("/runs/{runId}/resume")
    public Result<Map<String, Object>> resumeRun(@PathVariable String runId, @RequestBody Map<String, String> body) {
        if (!isValidId(runId)) {
            return Result.error("非法 runId 格式");
        }
        String clarifyId = body.get("clarifyId");
        String answer = body.get("answer");
        if (clarifyId == null || clarifyId.isBlank()) {
            return Result.error("缺少 clarifyId");
        }
        if (!isValidId(clarifyId)) {
            return Result.error("非法 clarifyId 格式");
        }
        // 更新 WorkflowPause 状态
        try {
            workflowPauseRepository.findByClarifyId(clarifyId).ifPresent(p -> {
                p.setAnswer(answer);
                p.setAnsweredAt(java.time.LocalDateTime.now());
                p.setStatus("ANSWERED");
                workflowPauseRepository.save(p);
            });
        } catch (Exception e) {
            log.warn("[Resume] 更新 WorkflowPause 失败 clarifyId={}: {}", clarifyId, e.getMessage());
        }
        // 转发给 Bridge，唤醒阻塞线程
        boolean resolved = false;
        if (hermesBridgeClient != null) {
            try {
                resolved = hermesBridgeClient.submitClarifyAnswer(clarifyId, answer);
            } catch (Exception e) {
                log.warn("[Resume] 调用 Bridge submitClarifyAnswer 失败 clarifyId={}: {}", clarifyId, e.getMessage());
            }
        } else {
            log.warn("[Resume] HermesBridgeClient 未启用，无法唤醒 clarifyId={}", clarifyId);
        }
        return Result.success(Map.of("resolved", resolved));
    }

    /**
     * v1.1：断线重连入口 —— 按 runId 重订阅 SseEventBus。
     *
     * <p>客户端 reader.read() 抛错时不应直接报失败，而是记录 lastEventId 后通过此端点续接。
     * 复用 SseEventBus.subscribe 内置的 Last-Event-ID 重放 + 活跃订阅注册。</p>
     *
     * @param runId        工作流运行 ID
     * @param lastEventId  HTTP 头部 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) {
        if (!isValidId(runId)) {
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.BAD_REQUEST, "非法 runId 格式");
        }
        Long lastEventId = lastEventIdHeader != null ? lastEventIdHeader : lastEventIdQuery;
        SseEmitter emitter = sseEventBus.subscribe(runId, lastEventId);
        if (emitter == null) {
            // M-1：runId 不存在或已超 TTL 时直接抛 404，让前端通过 HTTP 状态码识别；
            // 不再伪装成 SSE 流发 workflow_error，避免客户端建立连接后才发现失败、浪费资源。
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.NOT_FOUND,
                    "运行不存在或已过期: " + runId);
        }
        SseHeartbeats.start(emitter);
        return emitter;
    }

    /**
     * v1.1：查询运行状态 + 当前未答暂停点。
     *
     * <p>用户离开几小时后回来时，前端先调此端点：
     * 若 status=RUNNING 且 currentPause 非空，立即恢复 Askfor UI；
     * 然后再调 /stream 断线重连续接 SSE。</p>
     */
    @GetMapping("/runs/{runId}/state")
    public Result<Map<String, Object>> getRunState(@PathVariable String runId) {
        if (!isValidId(runId)) {
            return Result.error("非法 runId 格式");
        }
        Map<String, Object> state = new LinkedHashMap<>();
        // 工作流状态：通过 runId 精确查询（避免 findAll 全表扫描）
        try {
            java.util.Optional<WorkflowRun> run = workflowRunRepository.findByRunId(runId);
            if (run.isPresent()) {
                state.put("status", run.get().getStatus());
            } else {
                state.put("status", "UNKNOWN");
            }
        } catch (Exception e) {
            state.put("status", "UNKNOWN");
        }
        // 当前未答暂停点
        Map<String, Object> pause = null;
        try {
            java.util.Optional<WorkflowPause> p =
                    workflowPauseRepository.findFirstByRunIdAndStatusOrderByCreatedAtDesc(runId, "UNANSWERED");
            if (p.isPresent()) {
                WorkflowPause wp = p.get();
                pause = new LinkedHashMap<>();
                pause.put("clarifyId", wp.getClarifyId());
                pause.put("nodeId", wp.getNodeId()); // 前端按 nodeId 过滤 AskforPanel
                pause.put("question", wp.getQuestion());
                pause.put("choices", wp.getChoices()); // JSON 字符串，前端自行解析
                // H-5：固定使用 ISO_LOCAL_DATE_TIME，避免 toString() 因纳秒为零时省略毫秒部分导致前端解析不一致
                pause.put("createdAt", wp.getCreatedAt().format(PAUSE_TIME_FMT));
            }
        } catch (Exception e) {
            log.warn("[State] 查询 WorkflowPause 失败 runId={}: {}", runId, e.getMessage());
        }
        state.put("currentPause", pause);
        return Result.success(state);
    }

    /**
     * 下载运行工作目录的 zip 打包
     */
    @GetMapping("/{name}/runs/{runId}/download")
    public ResponseEntity<Resource> downloadRunDir(@PathVariable String name, @PathVariable String runId) throws IOException {
        Workflow wf = workflowService.getWorkflow(name);
        Path runDir = runDirManager.getRunDir(wf.getId(), wf.getName(), runId);
        if (runDir == null) {
            return ResponseEntity.notFound().build();
        }

        // 创建临时 zip 文件
        Path zipPath = Files.createTempFile("workflow-run-" + runId, ".zip");
        try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipPath))) {
            Files.walk(runDir)
                    .filter(p -> !Files.isDirectory(p) && shouldIncludeInZip(runDir, p))
                    .forEach(p -> {
                        ZipEntry entry = new ZipEntry(runDir.relativize(p).toString().replace('\\', '/'));
                        try {
                            zos.putNextEntry(entry);
                            Files.copy(p, zos);
                            zos.closeEntry();
                        } catch (IOException e) {
                            log.warn("[Download] 写入 zip 条目失败: {}", p, e);
                        }
                    });
        }

        Resource resource = new UrlResource(zipPath.toUri());
        String filename = URLEncoder.encode("workflow-" + wf.getName() + "-run-" + runId + ".zip", StandardCharsets.UTF_8);
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename)
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(resource);
    }

    /**
     * 判断工作目录中的文件是否应被包含进 zip 打包。
     * 应用 .agentignore 规则过滤（路径自身或任意祖先目录命中均排除）。
     */
    private boolean shouldIncludeInZip(Path runDir, Path p) {
        Path rel = runDir.relativize(p);
        String relStr = rel.toString().replace('\\', '/');
        if (agentIgnoreFilter.shouldIgnore(relStr, false)) return false;
        Path cur = rel;
        while (cur != null && cur.getNameCount() > 1) {
            cur = cur.getParent();
            if (cur == null) break;
            if (agentIgnoreFilter.shouldIgnore(cur.toString().replace('\\', '/'), true)) return false;
        }
        return true;
    }

    // ==================== 版本管理 ====================

    @GetMapping("/{name}/versions")
    public Result<List<WorkflowVersionVO>> listVersions(@PathVariable String name) {
        Workflow wf = workflowService.getWorkflow(name);
        return Result.success(workflowVersionService.listVersions(wf.getId(), wf.getName()));
    }

    @GetMapping("/{name}/versions/{version}/content")
    public Result<String> getVersionContent(@PathVariable String name, @PathVariable int version) {
        Workflow wf = workflowService.getWorkflow(name);
        return Result.success(workflowVersionService.getVersionContent(wf.getId(), wf.getName(), version));
    }

    @PostMapping("/{name}/versions/{version}/rollback")
    public Result<WorkflowVO> rollbackVersion(@PathVariable String name, @PathVariable int version) {
        Workflow wf = workflowService.getWorkflow(name);
        // 读取快照内容恢复到工作流
        String snapshotJson = workflowVersionService.getVersionContent(wf.getId(), wf.getName(), version);
        try {
            WorkflowVersionServiceImpl.SnapshotData snapshot = MAPPER.readValue(snapshotJson, WorkflowVersionServiceImpl.SnapshotData.class);
            Workflow updated = workflowService.updateWorkflow(name, snapshot.getName(), null, null, snapshot.getGraphData(), null);
            workflowVersionService.rollback(wf.getId(), wf.getName(), version);
            return Result.success(toVO(updated));
        } catch (IOException e) {
            return Result.error("解析快照数据失败");
        }
    }

    @PutMapping("/{name}/versions/{version}/message")
    public Result<Void> updateVersionMessage(@PathVariable String name, @PathVariable int version,
                                              @RequestBody Map<String, String> body) {
        Workflow wf = workflowService.getWorkflow(name);
        workflowVersionService.updateVersionMessage(wf.getId(), wf.getName(), version, body.get("message"));
        return Result.success(null);
    }

    /**
     * 自动关联分析：语义识别 Skill/SmartAction 节点隐式引用的其他 Skill
     */
    @PostMapping("/auto-associate")
    public Result<List<AssociationVO>> autoAssociate(@RequestBody AutoAssociateReq req) {
        try {
            JsonNode root = MAPPER.readTree(req.getGraphData());
            JsonNode nodesArr = root.path("nodes");
            if (nodesArr.isEmpty()) return Result.success(List.of());

            // 1. 提取 Skill 和 SmartAction 节点信息
            StringBuilder nodeInfo = new StringBuilder();
            Set<String> existingSkillIds = new java.util.LinkedHashSet<>();
            for (JsonNode node : nodesArr) {
                String type = node.path("type").asText("");
                JsonNode data = node.path("data");
                String nodeId = node.get("id").asText();
                String label = data.path("label").asText(nodeId);

                if ("skill".equals(type)) {
                    String skillId = data.path("skillId").asText("");
                    existingSkillIds.add(skillId);
                    String desc = "";
                    String body = "";
                    try {
                        SkillDTO dto = skillService.getSkill(skillId);
                        desc = dto.getDescription() != null ? dto.getDescription() : "";
                        String full = skillService.getSkillFullContent(skillId);
                        body = truncateBody(full, 500);
                    } catch (Exception ignored) {}
                    nodeInfo.append(String.format("- 节点ID: %s [skill] 名称: %s\n  描述: %s\n  内容摘要: %s\n\n",
                            nodeId, label, desc, body));
                } else if ("smartAction".equals(type)) {
                    String prompt = data.path("actionPrompt").asText("");
                    nodeInfo.append(String.format("- 节点ID: %s [smartAction] 名称: %s\n  操作要求: %s\n\n",
                            nodeId, label, prompt));
                }
            }

            if (nodeInfo.isEmpty()) return Result.success(List.of());

            // 2. 获取所有可用技能（排除已在图中的）
            List<SkillDTO> allSkills = skillService.listSkills();
            StringBuilder skillList = new StringBuilder();
            for (SkillDTO s : allSkills) {
                if (existingSkillIds.contains(s.getFolderName())) continue;
                String desc = s.getDescription() != null ? s.getDescription() : "无描述";
                skillList.append(String.format("- %s (目录: %s): %s\n", s.getName(), s.getFolderName(), desc));
            }

            if (skillList.isEmpty()) return Result.success(List.of());

            // 3. 调用 LLM
            String prompt = """
                    分析以下工作流节点内容，识别是否隐式引用了某个可用技能的能力。

                    工作流节点：
                    %s

                    可用技能列表（已排除工作流中已有的技能节点）：
                    %s

                    规则：分析每个节点的内容和描述，判断是否隐式需要某个可用技能。只返回明确的语义关联，不要猜测。同一技能被多个节点引用时每个关联单独返回。
                    返回JSON数组：[{"sourceNodeId":"节点ID","skillFolderName":"技能目录名"}]，无关联返回[]。只返回JSON不要其他内容。
                    """.formatted(nodeInfo, skillList);

            String response;
            try {
                response = aiModelService.systemChatCall(client ->
                        client.prompt().user(prompt).call().content());
            } catch (Exception e) {
                return Result.error("模型调用失败: " + e.getMessage());
            }
            return Result.success(parseAssociations(response));
        } catch (Exception e) {
            log.error("[AutoAssociate] 分析失败", e);
            return Result.error("关联分析失败: " + e.getMessage());
        }
    }

    private String truncateBody(String content, int maxLen) {
        // 跳过 frontmatter（两个 --- 之间的部分）
        int bodyStart = 0;
        int dashCount = 0;
        for (int i = 0; i < content.length() - 2; i++) {
            if (content.charAt(i) == '-' && content.charAt(i + 1) == '-' && content.charAt(i + 2) == '-') {
                dashCount++;
                if (dashCount == 2) {
                    bodyStart = i + 3;
                    break;
                }
                // 跳过这一行剩余的 ---
                while (i < content.length() && content.charAt(i) != '\n') i++;
            }
        }
        String body = content.substring(Math.min(bodyStart, content.length())).trim();
        return body.length() > maxLen ? body.substring(0, maxLen) + "..." : body;
    }

    private List<AssociationVO> parseAssociations(String response) {
        try {
            // 去掉 markdown 代码块
            String json = response.trim();
            if (json.startsWith("```")) {
                int start = json.indexOf('\n') + 1;
                int end = json.lastIndexOf("```");
                json = json.substring(start, end).trim();
            }
            JsonNode arr = MAPPER.readTree(json);
            if (!arr.isArray()) return List.of();
            List<AssociationVO> result = new ArrayList<>();
            for (JsonNode item : arr) {
                AssociationVO vo = new AssociationVO();
                vo.setSourceNodeId(item.path("sourceNodeId").asText(""));
                vo.setSkillFolderName(item.path("skillFolderName").asText(""));
                if (!vo.getSourceNodeId().isEmpty() && !vo.getSkillFolderName().isEmpty()) {
                    // 填充技能名称
                    try {
                        SkillDTO dto = skillService.getSkill(vo.getSkillFolderName());
                        vo.setSkillName(dto.getName());
                    } catch (Exception e) {
                        vo.setSkillName(vo.getSkillFolderName());
                    }
                    result.add(vo);
                }
            }
            return result;
        } catch (Exception e) {
            log.warn("[AutoAssociate] 解析LLM响应失败: {}", response);
            return List.of();
        }
    }

    private WorkflowVO toVO(Workflow wf) {
        WorkflowVO vo = WorkflowVO.from(wf);
        vo.setTags(tagService.getEntityTags("workflow", String.valueOf(wf.getId())));
        // 填充分类名称
        if (wf.getCategoryId() != null) {
            wfCategoryRepository.findById(wf.getCategoryId())
                    .ifPresent(cat -> vo.setCategoryName(cat.getName()));
        }
        return vo;
    }

    @Data
    public static class CreateWorkflowReq {
        private String name;
        private String displayName;
        private String description;
        /** 运行模式：DAG / AGENTIC（可选，默认 DAG） */
        private String mode;
    }

    @Data
    public static class SaveWorkflowReq {
        private String name;
        private String displayName;
        private String description;
        private String graphData;
        /** 运行模式：DAG / AGENTIC（可选，null 表示不变更） */
        private String mode;
    }

    @Data
    public static class SetCategoryReq {
        private Long categoryId;
    }

    /** AI 生成描述响应：skipped=true 表示当前描述已清晰无需修改 */
    @Data
    public static class GenerateDescResp {
        private final boolean skipped;
        private final String reason;
        private final String description;
    }

    /** 仅更新描述请求 */
    @Data
    public static class UpdateDescReq {
        private String description;
    }

    @Data
    public static class AutoAssociateReq {
        private String graphData;
    }

    @Data
    public static class AssociationVO {
        private String sourceNodeId;
        private String skillFolderName;
        private String skillName;
    }
}
