package com.gzzm.lobster.tool.builtin;

import com.fasterxml.jackson.core.type.TypeReference;
import com.gzzm.lobster.artifact.Artifact;
import com.gzzm.lobster.artifact.ArtifactService;
import com.gzzm.lobster.common.ArtifactType;
import com.gzzm.lobster.common.IdGenerator;
import com.gzzm.lobster.common.JsonUtil;
import com.gzzm.lobster.common.ResourceSourceType;
import com.gzzm.lobster.identity.UserContext;
import com.gzzm.lobster.thread.ThreadRoom;
import com.gzzm.lobster.thread.ThreadService;
import com.gzzm.lobster.tool.BuiltinToolDefinition;
import com.gzzm.lobster.tool.SchemaBuilder;
import com.gzzm.lobster.tool.ToolContext;
import com.gzzm.lobster.tool.ToolRegistry;
import com.gzzm.lobster.tool.ToolResult;
import com.gzzm.lobster.workspace.WorkspaceResource;
import com.gzzm.lobster.workspace.ResourceMetadata;
import com.gzzm.lobster.workspace.WorkspaceService;
import net.cyan.nest.annotation.Inject;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * LongDocumentTools —— 长文档分段生成的最小强约束工具集.
 *
 * <p>不引入新表，先复用 Artifact / WorkspaceResource：
 * plan + ledger + leaf markdown/meta + merged markdown 全部是 workspace artifacts.
 */
public class LongDocumentTools {

    @Inject private ThreadService threadService;
    @Inject private WorkspaceService workspaceService;
    @Inject private ArtifactService artifactService;

    public void registerTo(ToolRegistry registry) {
        registry.register(createProjectDef(), this::createProject);
        registry.register(updateNodeDef(), this::updateNode);
        registry.register(mergeDef(), this::merge);
    }

    private BuiltinToolDefinition createProjectDef() {
        return BuiltinToolDefinition.builder()
                .name("long_doc_create_project")
                .displayName("创建长文档项目")
                .description("为超长文档创建结构化计划和任务台账。"
                        + "先由模型生成章节树 outlineJson，再调用本工具落盘 document_plan 和 task_ledger。"
                        + "后续叶子正文必须通过 long_doc_update_node 保存，全文合并必须通过 long_doc_merge。")
                .category(com.gzzm.lobster.common.ToolCategory.WORKSPACE)
                .risk(com.gzzm.lobster.common.ToolRiskLevel.WRITE)
                .inputSchema(SchemaBuilder.obj()
                        .prop("title", "string", "文档标题")
                        .prop("documentType", "string", "文档类型，如 research_report / implementation_plan / work_summary")
                        .prop("audience", "string", "读者或报送对象")
                        .propInt("targetLength", "目标总字数或中文字符数")
                        .propInt("tolerancePercent", "允许偏差百分比，默认 10")
                        .prop("language", "string", "语言，默认 zh-CN")
                        .prop("styleNotes", "string", "写作风格、格式、口径约束")
                        .prop("outlineJson", "string", "章节树 JSON 数组。节点字段建议包含 id/title/target_length/key_points/source_refs/children")
                        .prop("sourcePolicyJson", "string", "可选 JSON 对象，事实来源和缺失值策略")
                        .prop("acceptanceCriteriaJson", "string", "可选 JSON 数组，验收标准")
                        .required("title", "outlineJson")
                        .build())
                .build();
    }

    private ToolResult createProject(ToolContext ctx, Map<String, Object> args) throws Exception {
        ThreadRoom thread = threadService.requireOwnedThread(ctx.getUserContext(), ctx.getThreadId());
        UserContext user = ctx.getUserContext();
        String title = requiredStr(args.get("title"), "title");
        String projectId = IdGenerator.prefixed("ldoc_");

        Object outlineObj = parseJsonValue(args.get("outlineJson"));
        List<Object> outline = asList(outlineObj);
        if (outline == null || outline.isEmpty()) {
            return ToolResult.error("long_doc_create_project.invalid_outline: outlineJson must be a non-empty JSON array");
        }

        Map<String, Object> target = new LinkedHashMap<>();
        target.put("unit", "chinese_chars");
        target.put("total", Math.max(0, asInt(args.get("targetLength"), 0)));
        target.put("tolerance_percent", Math.max(0, asInt(args.get("tolerancePercent"), 10)));

        Map<String, Object> style = new LinkedHashMap<>();
        style.put("tone", asStr(args.get("styleNotes")));
        style.put("language", emptyToDefault(asStr(args.get("language")), "zh-CN"));

        Map<String, Object> sourcePolicy = asMap(parseJsonValue(args.get("sourcePolicyJson")));
        if (sourcePolicy == null) sourcePolicy = new LinkedHashMap<>();
        if (!sourcePolicy.containsKey("may_infer")) sourcePolicy.put("may_infer", Boolean.FALSE);
        if (!sourcePolicy.containsKey("missing_value_marker")) sourcePolicy.put("missing_value_marker", "待明确");

        List<Object> acceptance = asList(parseJsonValue(args.get("acceptanceCriteriaJson")));
        if (acceptance == null || acceptance.isEmpty()) {
            acceptance = new ArrayList<>();
            acceptance.add("all leaf nodes generated");
            acceptance.add("actual length within tolerance");
            acceptance.add("no missing required sections");
            acceptance.add("heading hierarchy is consistent");
            acceptance.add("facts and source-sensitive claims are not invented");
        }

        Map<String, Object> plan = new LinkedHashMap<>();
        plan.put("project_id", projectId);
        plan.put("title", title);
        plan.put("audience", asStr(args.get("audience")));
        plan.put("document_type", asStr(args.get("documentType")));
        plan.put("target_length", target);
        plan.put("style", style);
        plan.put("source_policy", sourcePolicy);
        plan.put("outline", outline);
        plan.put("acceptance_criteria", acceptance);

        List<String> outlineStructureErrors = validateOutlineStructure(outline);
        if (!outlineStructureErrors.isEmpty()) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("errors", outlineStructureErrors);
            return ToolResult.errorData("long_doc_create_project.invalid_outline: " + outlineStructureErrors, data);
        }
        List<Map<String, Object>> leaves = new ArrayList<>();
        flattenLeaves(outline, "", leaves);
        List<String> outlineErrors = validateLeaves(leaves);
        if (!outlineErrors.isEmpty()) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("errors", outlineErrors);
            return ToolResult.errorData("long_doc_create_project.invalid_outline: " + outlineErrors, data);
        }

        Map<String, Object> ledger = new LinkedHashMap<>();
        ledger.put("project_id", projectId);
        ledger.put("project_status", "drafting");
        ledger.put("current_node_id", null);
        List<Map<String, Object>> nodes = new ArrayList<>();
        for (Map<String, Object> leaf : leaves) {
            Map<String, Object> n = new LinkedHashMap<>();
            n.put("id", asStr(leaf.get("id")));
            n.put("title", asStr(leaf.get("title")));
            n.put("path", asStr(leaf.get("path")));
            n.put("status", "pending");
            n.put("target_length", asInt(firstNonNull(leaf.get("target_length"), leaf.get("targetLength")), 0));
            n.put("actual_length", 0);
            n.put("issues", new ArrayList<Object>());
            nodes.add(n);
        }
        ledger.put("nodes", nodes);
        ledger.put("global_terms", new ArrayList<Object>());
        ledger.put("open_questions", new ArrayList<Object>());
        ledger.put("validation", emptyValidation());

        Artifact planArtifact = artifactService.create(thread, user, ArtifactType.TASK_LEDGER,
                safeTitle(title, "document-plan.json"), pretty(plan), "json", null, ctx.getRunId(), null);
        WorkspaceResource planResource = workspaceService.registerArtifact(thread, user, planArtifact,
                lifecycleMeta("STATE", "internal"));
        Artifact ledgerArtifact = artifactService.create(thread, user, ArtifactType.TASK_LEDGER,
                safeTitle(title, "task-ledger.json"), pretty(ledger), "json", null, ctx.getRunId(), null);
        WorkspaceResource ledgerResource = workspaceService.registerArtifact(thread, user, ledgerArtifact,
                lifecycleMeta("STATE", "internal"));

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("projectId", projectId);
        data.put("planResourceId", planResource.getResourceId());
        data.put("planArtifactId", planArtifact.getArtifactId());
        data.put("ledgerResourceId", ledgerResource.getResourceId());
        data.put("ledgerArtifactId", ledgerArtifact.getArtifactId());
        data.put("leafCount", leaves.size());
        data.put("nextAction", "Draft each leaf with long_doc_update_node, then call long_doc_merge.");
        List<String> artifactIds = new ArrayList<>();
        artifactIds.add(planArtifact.getArtifactId());
        artifactIds.add(ledgerArtifact.getArtifactId());
        return ToolResult.okWithArtifacts("长文档项目已创建", data, artifactIds);
    }

    private BuiltinToolDefinition updateNodeDef() {
        return BuiltinToolDefinition.builder()
                .name("long_doc_update_node")
                .displayName("保存长文档节点")
                .description("保存或覆盖一个叶子节点 Markdown、节点 meta，并更新长文档任务台账。"
                        + "每次生成或修订叶子正文后都必须调用本工具，避免只在对话里输出正文。")
                .category(com.gzzm.lobster.common.ToolCategory.WORKSPACE)
                .risk(com.gzzm.lobster.common.ToolRiskLevel.WRITE)
                .inputSchema(SchemaBuilder.obj()
                        .prop("planResourceId", "string", "long_doc_create_project 返回的计划 resourceId")
                        .prop("ledgerResourceId", "string", "long_doc_create_project 返回的台账 resourceId")
                        .prop("nodeId", "string", "叶子节点 id")
                        .prop("title", "string", "节点标题，可选")
                        .prop("markdown", "string", "当前叶子节点 Markdown 正文")
                        .prop("summary", "string", "当前叶子节点摘要，用于后续上下文")
                        .prop("status", "string", "节点状态，默认 drafted；可传 drafted/reviewed/needs_rewrite")
                        .propInt("targetLength", "节点目标字数；为空时沿用 plan/ledger")
                        .prop("metaJson", "string", "可选 JSON 对象，补充 key_claims/source_refs_used/terms/continuity/quality")
                        .required("planResourceId", "ledgerResourceId", "nodeId", "markdown", "summary")
                        .build())
                .build();
    }

    private ToolResult updateNode(ToolContext ctx, Map<String, Object> args) throws Exception {
        ThreadRoom thread = threadService.requireOwnedThread(ctx.getUserContext(), ctx.getThreadId());
        UserContext user = ctx.getUserContext();
        String planResourceId = requiredStr(args.get("planResourceId"), "planResourceId");
        String ledgerResourceId = requiredStr(args.get("ledgerResourceId"), "ledgerResourceId");
        String nodeId = requiredStr(args.get("nodeId"), "nodeId");
        String markdown = requiredStr(args.get("markdown"), "markdown");
        String summary = requiredStr(args.get("summary"), "summary");
        String status = emptyToDefault(asStr(args.get("status")), "drafted");

        Map<String, Object> plan = readJsonResource(ctx, planResourceId);
        Map<String, Object> ledger = readJsonResource(ctx, ledgerResourceId);
        String projectError = validateSameProject(plan, ledger);
        if (projectError != null) return ToolResult.error(projectError);
        Map<String, Object> planNode = findLeafById(asList(plan.get("outline")), nodeId);
        if (planNode == null) {
            return ToolResult.error("long_doc_update_node.unknown_node: nodeId not found in plan: " + nodeId);
        }
        Map<String, Object> ledgerNode = getOrCreateLedgerNode(ledger, nodeId);

        String title = emptyToDefault(asStr(args.get("title")),
                emptyToDefault(asStr(firstNonNull(ledgerNode.get("title"), planNode == null ? null : planNode.get("title"))), nodeId));
        int targetLength = asInt(args.get("targetLength"),
                asInt(firstNonNull(ledgerNode.get("target_length"), planNode == null ? null : planNode.get("target_length")), 0));
        int actualLength = countContentChars(markdown);

        String sectionResourceId = asStr(firstNonNull(ledgerNode.get("markdownResourceId"), ledgerNode.get("sectionResourceId")));
        String metaResourceId = asStr(ledgerNode.get("metaResourceId"));
        String reservedError = validateNotReservedNodeResource(sectionResourceId, "markdownResourceId", planResourceId, ledgerResourceId);
        if (reservedError != null) return ToolResult.error(reservedError);
        reservedError = validateNotReservedNodeResource(metaResourceId, "metaResourceId", planResourceId, ledgerResourceId);
        if (reservedError != null) return ToolResult.error(reservedError);

        ArtifactRef sectionRef = writeOrOverwriteText(thread, user, ctx, sectionResourceId,
                nodeId + "-" + title + ".md", markdown, "md", ArtifactType.GENERATED_DOCUMENT,
                lifecycleMeta("INTERMEDIATE", "internal"));

        Map<String, Object> meta = asMap(parseJsonValue(args.get("metaJson")));
        if (meta == null) meta = new LinkedHashMap<>();
        meta.put("node_id", nodeId);
        meta.put("title", title);
        meta.put("status", status);
        meta.put("target_length", targetLength);
        meta.put("actual_length", actualLength);
        meta.put("summary", summary);
        if (!meta.containsKey("quality")) {
            Map<String, Object> quality = new LinkedHashMap<>();
            quality.put("missing_facts", new ArrayList<Object>());
            quality.put("risk_notes", new ArrayList<Object>());
            quality.put("needs_user_confirmation", new ArrayList<Object>());
            meta.put("quality", quality);
        }
        ArtifactRef metaRef = writeOrOverwriteText(thread, user, ctx, metaResourceId,
                nodeId + "-" + title + ".meta.json", pretty(meta), "json", ArtifactType.SUMMARY_SNAPSHOT,
                lifecycleMeta("STATE", "internal"));

        ledgerNode.put("id", nodeId);
        ledgerNode.put("title", title);
        ledgerNode.put("status", status);
        ledgerNode.put("target_length", targetLength);
        ledgerNode.put("actual_length", actualLength);
        ledgerNode.put("summary", summary);
        ledgerNode.put("markdownResourceId", sectionRef.resourceId);
        ledgerNode.put("markdownArtifactId", sectionRef.artifactId);
        ledgerNode.put("sectionResourceId", sectionRef.resourceId);
        ledgerNode.put("metaResourceId", metaRef.resourceId);
        ledgerNode.put("metaArtifactId", metaRef.artifactId);
        ledger.put("current_node_id", nodeId);
        ledger.put("project_status", "drafting");
        recomputeValidation(ledger);
        overwriteTextResource(ctx, ledgerResourceId, pretty(ledger), null);

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("nodeId", nodeId);
        data.put("status", status);
        data.put("targetLength", targetLength);
        data.put("actualLength", actualLength);
        data.put("markdownResourceId", sectionRef.resourceId);
        data.put("markdownArtifactId", sectionRef.artifactId);
        data.put("metaResourceId", metaRef.resourceId);
        data.put("metaArtifactId", metaRef.artifactId);
        data.put("ledgerResourceId", ledgerResourceId);
        List<String> artifactIds = new ArrayList<>();
        artifactIds.add(sectionRef.artifactId);
        artifactIds.add(metaRef.artifactId);
        return ToolResult.okWithArtifacts("长文档节点已保存", data, artifactIds);
    }

    private BuiltinToolDefinition mergeDef() {
        return BuiltinToolDefinition.builder()
                .name("long_doc_merge")
                .displayName("合并长文档 Markdown")
                .description("按 document_plan 的章节树顺序合并已保存的叶子 Markdown。"
                        + "默认缺少叶子节点时拒绝合并；章节 Word 只能作为预览，最终 Word/PDF 应从本工具产出的 full.md 一次渲染。")
                .category(com.gzzm.lobster.common.ToolCategory.WORKSPACE)
                .risk(com.gzzm.lobster.common.ToolRiskLevel.WRITE)
                .inputSchema(SchemaBuilder.obj()
                        .prop("planResourceId", "string", "计划 resourceId")
                        .prop("ledgerResourceId", "string", "台账 resourceId")
                        .propEnum("scope", "合并范围，默认 FULL", "FULL", "CHAPTER")
                        .prop("chapterId", "string", "scope=CHAPTER 时要合并的章/节点 id")
                        .propBool("allowPartial", "是否允许缺少叶子节点时仍合并，默认 false")
                        .required("planResourceId", "ledgerResourceId")
                        .build())
                .build();
    }

    private ToolResult merge(ToolContext ctx, Map<String, Object> args) throws Exception {
        ThreadRoom thread = threadService.requireOwnedThread(ctx.getUserContext(), ctx.getThreadId());
        UserContext user = ctx.getUserContext();
        String planResourceId = requiredStr(args.get("planResourceId"), "planResourceId");
        String ledgerResourceId = requiredStr(args.get("ledgerResourceId"), "ledgerResourceId");
        String scope = emptyToDefault(asStr(args.get("scope")), "FULL").toUpperCase();
        if (!"FULL".equals(scope) && !"CHAPTER".equals(scope)) {
            return ToolResult.error("long_doc_merge.invalid_scope: scope must be FULL or CHAPTER");
        }
        String chapterId = asStr(args.get("chapterId"));
        boolean allowPartial = asBool(args.get("allowPartial"), false);

        Map<String, Object> plan = readJsonResource(ctx, planResourceId);
        Map<String, Object> ledger = readJsonResource(ctx, ledgerResourceId);
        String projectError = validateSameProject(plan, ledger);
        if (projectError != null) return ToolResult.error(projectError);
        List<Map<String, Object>> leaves = new ArrayList<>();
        List<Object> outline = asList(plan.get("outline"));
        if ("CHAPTER".equals(scope)) {
            if (chapterId == null || chapterId.isEmpty()) {
                return ToolResult.error("chapterId required when scope=CHAPTER");
            }
            Map<String, Object> chapter = findNodeById(outline, chapterId);
            if (chapter == null) return ToolResult.error("chapter not found in plan: " + chapterId);
            List<Object> one = new ArrayList<>();
            one.add(chapter);
            flattenLeaves(one, "", leaves);
        } else {
            flattenLeaves(outline, "", leaves);
        }
        if (leaves.isEmpty()) {
            return ToolResult.error("long_doc_merge.empty_outline: no leaf nodes found in plan");
        }

        List<String> missing = new ArrayList<>();
        List<String> needsRewrite = new ArrayList<>();
        List<String> mergedNodeIds = new ArrayList<>();
        StringBuilder merged = new StringBuilder();
        Map<String, Map<String, Object>> ledgerById = ledgerNodesById(ledger);
        for (Map<String, Object> leaf : leaves) {
            String nodeId = asStr(leaf.get("id"));
            if (nodeId == null || nodeId.isEmpty()) continue;
            Map<String, Object> n = ledgerById.get(nodeId);
            String mdRid = n == null ? null : asStr(firstNonNull(n.get("markdownResourceId"), n.get("sectionResourceId")));
            String nodeStatus = n == null ? null : asStr(n.get("status"));
            if ("needs_rewrite".equalsIgnoreCase(nodeStatus)) {
                needsRewrite.add(nodeId);
                continue;
            }
            if (mdRid == null || mdRid.isEmpty()) {
                missing.add(nodeId);
                continue;
            }
            String content = workspaceService.readResource(ctx.getUserContext(), ctx.getThreadId(), mdRid, 0, 0);
            if (content == null || content.trim().isEmpty()) {
                missing.add(nodeId);
                continue;
            }
            if (merged.length() > 0) merged.append("\n\n");
            merged.append(content.trim()).append("\n");
            mergedNodeIds.add(nodeId);
            if (n != null) n.put("status", "merged");
        }

        if (!missing.isEmpty() && !allowPartial) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("missingNodeIds", missing);
            data.put("mergedNodeIds", mergedNodeIds);
            data.put("message", "Set allowPartial=true only for preview merges.");
            return ToolResult.errorData("long_doc_merge.missing_nodes: " + missing, data);
        }
        if (!needsRewrite.isEmpty()) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("needsRewriteNodeIds", needsRewrite);
            data.put("mergedNodeIds", mergedNodeIds);
            data.put("message", "Rewrite these nodes with long_doc_update_node before merging.");
            return ToolResult.errorData("long_doc_merge.needs_rewrite_nodes: " + needsRewrite, data);
        }
        if (mergedNodeIds.isEmpty()) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("missingNodeIds", missing);
            data.put("needsRewriteNodeIds", needsRewrite);
            return ToolResult.errorData("long_doc_merge.empty_result: no nodes were merged", data);
        }

        String title = emptyToDefault(asStr(plan.get("title")), "long-document");
        String filename = "CHAPTER".equals(scope)
                ? chapterId + "-" + title + ".md"
                : title + "-full.md";
        Artifact artifact = artifactService.create(thread, user, ArtifactType.MERGED_RESULT,
                filename, merged.toString(), "md", null, ctx.getRunId(), null);
        WorkspaceResource resource = workspaceService.registerArtifact(thread, user, artifact,
                lifecycleMeta("INTERMEDIATE", "supporting"));

        Map<String, Object> validation = asMap(ledger.get("validation"));
        if (validation == null) validation = emptyValidation();
        validation.put("missing_nodes", missing);
        ledger.put("validation", validation);
        if ("CHAPTER".equals(scope)) {
            Map<String, Object> chapterResources = asMap(ledger.get("chapterMergedResourceIds"));
            if (chapterResources == null) chapterResources = new LinkedHashMap<>();
            chapterResources.put(chapterId, resource.getResourceId());
            ledger.put("chapterMergedResourceIds", chapterResources);
        } else {
            ledger.put("fullMergedResourceId", resource.getResourceId());
            ledger.put("fullMergedArtifactId", artifact.getArtifactId());
            ledger.put("project_status", missing.isEmpty() ? "merged" : "merged_partial");
        }
        overwriteTextResource(ctx, ledgerResourceId, pretty(ledger), null);

        Map<String, Object> data = new LinkedHashMap<>();
        data.put("scope", scope);
        if ("CHAPTER".equals(scope)) data.put("chapterId", chapterId);
        data.put("mergedResourceId", resource.getResourceId());
        data.put("mergedArtifactId", artifact.getArtifactId());
        data.put("mergedNodeIds", mergedNodeIds);
        data.put("missingNodeIds", missing);
        data.put("actualLength", countContentChars(merged.toString()));
        data.put("preview", preview(merged.toString(), 800));
        return ToolResult.okWithArtifacts("长文档 Markdown 已合并", data,
                Collections.singletonList(artifact.getArtifactId()));
    }

    private Map<String, Object> readJsonResource(ToolContext ctx, String resourceId) throws Exception {
        String content = workspaceService.readResource(ctx.getUserContext(), ctx.getThreadId(), resourceId, 0, 0);
        Map<String, Object> map = JsonUtil.fromJson(content, new TypeReference<Map<String, Object>>() {});
        return map == null ? new LinkedHashMap<String, Object>() : map;
    }

    private ArtifactRef writeOrOverwriteText(ThreadRoom thread, UserContext user, ToolContext ctx,
                                             String resourceId, String title, String content,
                                             String format, ArtifactType type,
                                             String metadataJson) throws Exception {
        if (resourceId != null && !resourceId.isEmpty()) {
            return overwriteTextResource(ctx, resourceId, content, title, metadataJson);
        }
        Artifact a = artifactService.create(thread, user, type, title, content, format, null, ctx.getRunId(), null);
        WorkspaceResource r = workspaceService.registerArtifact(thread, user, a, metadataJson);
        return new ArtifactRef(r.getResourceId(), a.getArtifactId());
    }

    private String lifecycleMeta(String role, String visibility) {
        return ResourceMetadata.writeArtifactLifecycle(null, role, visibility);
    }

    private ArtifactRef overwriteTextResource(ToolContext ctx, String resourceId, String content, String title) throws Exception {
        return overwriteTextResource(ctx, resourceId, content, title, null);
    }

    private ArtifactRef overwriteTextResource(ToolContext ctx, String resourceId, String content, String title,
                                              String metadataJson) throws Exception {
        WorkspaceResource r = workspaceService.getResource(resourceId);
        if (r == null) throw new IllegalArgumentException("resource not found: " + resourceId);
        if (!ctx.getThreadId().equals(r.getThreadId()) || !ctx.getUserId().equals(r.getUserId())) {
            throw new IllegalArgumentException("resource not owned by current thread/user: " + resourceId);
        }
        if (r.getSourceType() != ResourceSourceType.ARTIFACT && r.getSourceType() != ResourceSourceType.WORKSHOP_DOC) {
            throw new IllegalArgumentException("resource is not an artifact: " + resourceId);
        }
        Artifact a = artifactService.overwrite(r.getSourceId(), ctx.getUserContext(), content, title);
        if (metadataJson != null && !metadataJson.isEmpty()) {
            workspaceService.updateArtifactLifecycle(resourceId, ctx.getUserId(), metadataJson);
        }
        return new ArtifactRef(resourceId, a.getArtifactId());
    }

    @SuppressWarnings("unchecked")
    private static List<Object> asList(Object o) {
        if (o instanceof List) return (List<Object>) o;
        return null;
    }

    @SuppressWarnings("unchecked")
    private static Map<String, Object> asMap(Object o) {
        if (o instanceof Map) return (Map<String, Object>) o;
        return null;
    }

    private static Object parseJsonValue(Object value) {
        if (value == null) return null;
        if (value instanceof Map || value instanceof List) return value;
        String s = String.valueOf(value).trim();
        if (s.isEmpty()) return null;
        try {
            return JsonUtil.mapper().readValue(s, Object.class);
        } catch (Exception e) {
            throw new IllegalArgumentException("invalid JSON: " + e.getMessage(), e);
        }
    }

    private static void flattenLeaves(List<Object> nodes, String parentPath, List<Map<String, Object>> out) {
        if (nodes == null) return;
        for (Object o : nodes) {
            Map<String, Object> node = asMap(o);
            if (node == null) continue;
            String id = asStr(node.get("id"));
            String title = asStr(node.get("title"));
            String path = parentPath == null || parentPath.isEmpty()
                    ? emptyToDefault(title, id)
                    : parentPath + " / " + emptyToDefault(title, id);
            List<Object> children = asList(node.get("children"));
            if (children == null || children.isEmpty()) {
                Map<String, Object> leaf = new LinkedHashMap<>(node);
                leaf.put("path", path);
                out.add(leaf);
            } else {
                flattenLeaves(children, path, out);
            }
        }
    }

    private static Map<String, Object> findLeafById(List<Object> outline, String nodeId) {
        List<Map<String, Object>> leaves = new ArrayList<>();
        flattenLeaves(outline, "", leaves);
        for (Map<String, Object> leaf : leaves) {
            if (nodeId.equals(asStr(leaf.get("id")))) return leaf;
        }
        return null;
    }

    private static Map<String, Object> findNodeById(List<Object> nodes, String nodeId) {
        if (nodes == null) return null;
        for (Object o : nodes) {
            Map<String, Object> node = asMap(o);
            if (node == null) continue;
            if (nodeId.equals(asStr(node.get("id")))) return node;
            Map<String, Object> child = findNodeById(asList(node.get("children")), nodeId);
            if (child != null) return child;
        }
        return null;
    }

    private static Map<String, Object> getOrCreateLedgerNode(Map<String, Object> ledger, String nodeId) {
        List<Object> nodes = asList(ledger.get("nodes"));
        if (nodes == null) {
            nodes = new ArrayList<>();
            ledger.put("nodes", nodes);
        }
        for (Object o : nodes) {
            Map<String, Object> n = asMap(o);
            if (n != null && nodeId.equals(asStr(n.get("id")))) return n;
        }
        Map<String, Object> n = new LinkedHashMap<>();
        n.put("id", nodeId);
        n.put("status", "pending");
        n.put("issues", new ArrayList<Object>());
        nodes.add(n);
        return n;
    }

    private static List<String> validateOutlineStructure(List<Object> outline) {
        List<String> errors = new ArrayList<>();
        Map<String, Boolean> seen = new LinkedHashMap<>();
        validateOutlineNodes(outline, "", seen, errors);
        return errors;
    }

    private static void validateOutlineNodes(List<Object> nodes, String path,
                                             Map<String, Boolean> seen,
                                             List<String> errors) {
        if (nodes == null || nodes.isEmpty()) {
            errors.add("outline nodes required at " + emptyToDefault(path, "<root>"));
            return;
        }
        int index = 0;
        for (Object o : nodes) {
            String currentPath = (path == null || path.isEmpty()) ? ("[" + index + "]") : (path + ".children[" + index + "]");
            Map<String, Object> node = asMap(o);
            if (node == null) {
                errors.add("outline node must be object at " + currentPath);
                index++;
                continue;
            }
            String id = asStr(node.get("id"));
            if (id == null || id.trim().isEmpty()) {
                errors.add("node id required at " + currentPath);
            } else if (seen.containsKey(id)) {
                errors.add("duplicate node id: " + id);
            } else {
                seen.put(id, Boolean.TRUE);
            }
            Object childrenObj = node.get("children");
            if (childrenObj != null) {
                List<Object> children = asList(childrenObj);
                if (children == null) {
                    errors.add("children must be array at " + currentPath);
                } else if (!children.isEmpty()) {
                    validateOutlineNodes(children, currentPath, seen, errors);
                }
            }
            index++;
        }
    }

    private static String validateNotReservedNodeResource(String resourceId, String field,
                                                          String planResourceId, String ledgerResourceId) {
        if (resourceId == null || resourceId.isEmpty()) return null;
        if (resourceId.equals(planResourceId) || resourceId.equals(ledgerResourceId)) {
            return "long_doc_update_node.invalid_" + field + ": node resource must not point to plan or ledger";
        }
        return null;
    }

    private static List<String> validateLeaves(List<Map<String, Object>> leaves) {
        List<String> errors = new ArrayList<>();
        if (leaves == null || leaves.isEmpty()) {
            errors.add("outline has no leaf nodes");
            return errors;
        }
        Map<String, Boolean> seen = new LinkedHashMap<>();
        for (Map<String, Object> leaf : leaves) {
            String id = leaf == null ? null : asStr(leaf.get("id"));
            String title = leaf == null ? null : asStr(leaf.get("title"));
            if (id == null || id.trim().isEmpty()) {
                errors.add("leaf id required: " + emptyToDefault(title, "<untitled>"));
                continue;
            }
            if (seen.containsKey(id)) {
                errors.add("duplicate leaf id: " + id);
            } else {
                seen.put(id, Boolean.TRUE);
            }
        }
        return errors;
    }

    private static String validateSameProject(Map<String, Object> plan, Map<String, Object> ledger) {
        String planProjectId = asStr(plan == null ? null : plan.get("project_id"));
        String ledgerProjectId = asStr(ledger == null ? null : ledger.get("project_id"));
        if (planProjectId == null || planProjectId.isEmpty()) {
            return "long_doc.project_mismatch: plan has no project_id";
        }
        if (ledgerProjectId == null || ledgerProjectId.isEmpty()) {
            return "long_doc.project_mismatch: ledger has no project_id";
        }
        if (!planProjectId.equals(ledgerProjectId)) {
            return "long_doc.project_mismatch: plan project_id " + planProjectId
                    + " does not match ledger project_id " + ledgerProjectId;
        }
        return null;
    }

    private static Map<String, Map<String, Object>> ledgerNodesById(Map<String, Object> ledger) {
        Map<String, Map<String, Object>> out = new LinkedHashMap<>();
        List<Object> nodes = asList(ledger.get("nodes"));
        if (nodes == null) return out;
        for (Object o : nodes) {
            Map<String, Object> n = asMap(o);
            if (n == null) continue;
            String id = asStr(n.get("id"));
            if (id != null && !id.isEmpty()) out.put(id, n);
        }
        return out;
    }

    private static void recomputeValidation(Map<String, Object> ledger) {
        List<String> missing = new ArrayList<>();
        List<String> shortNodes = new ArrayList<>();
        List<Object> nodes = asList(ledger.get("nodes"));
        if (nodes != null) {
            for (Object o : nodes) {
                Map<String, Object> n = asMap(o);
                if (n == null) continue;
                String id = asStr(n.get("id"));
                String status = asStr(n.get("status"));
                if (status == null || "pending".equals(status)) missing.add(id);
                int target = asInt(n.get("target_length"), 0);
                int actual = asInt(n.get("actual_length"), 0);
                if (target > 0 && actual > 0 && actual < Math.floor(target * 0.8)) shortNodes.add(id);
            }
        }
        Map<String, Object> validation = asMap(ledger.get("validation"));
        if (validation == null) validation = emptyValidation();
        validation.put("missing_nodes", missing);
        validation.put("short_nodes", shortNodes);
        ledger.put("validation", validation);
    }

    private static Map<String, Object> emptyValidation() {
        Map<String, Object> v = new LinkedHashMap<>();
        v.put("missing_nodes", new ArrayList<Object>());
        v.put("short_nodes", new ArrayList<Object>());
        v.put("duplicated_points", new ArrayList<Object>());
        v.put("continuity_issues", new ArrayList<Object>());
        return v;
    }

    private static String pretty(Object o) throws Exception {
        return JsonUtil.mapper().writerWithDefaultPrettyPrinter().writeValueAsString(o);
    }

    private static String safeTitle(String title, String suffix) {
        String t = title == null ? "long-document" : title.trim();
        if (t.isEmpty()) t = "long-document";
        return t + "-" + suffix;
    }

    private static String preview(String s, int max) {
        if (s == null) return "";
        if (s.length() <= max) return s;
        return s.substring(0, max);
    }

    private static int countContentChars(String s) {
        if (s == null || s.isEmpty()) return 0;
        String noFence = s.replaceAll("(?s)```.*?```", "");
        String noMd = noFence.replaceAll("[#>*_`\\-\\[\\]\\(\\)!|]", "");
        String compact = noMd.replaceAll("\\s+", "");
        return compact.length();
    }

    private static Object firstNonNull(Object a, Object b) {
        return a != null ? a : b;
    }

    private static String requiredStr(Object o, String name) {
        String s = asStr(o);
        if (s == null || s.trim().isEmpty()) throw new IllegalArgumentException(name + " required");
        return s.trim();
    }

    private static String emptyToDefault(String s, String def) {
        return s == null || s.trim().isEmpty() ? def : s.trim();
    }

    private static String asStr(Object o) {
        return o == null ? null : String.valueOf(o);
    }

    private static int asInt(Object o, int def) {
        if (o == null) return def;
        if (o instanceof Number) return ((Number) o).intValue();
        try { return Integer.parseInt(String.valueOf(o)); } catch (Exception e) { return def; }
    }

    private static boolean asBool(Object o, boolean def) {
        if (o == null) return def;
        if (o instanceof Boolean) return (Boolean) o;
        return "true".equalsIgnoreCase(String.valueOf(o));
    }

    private static final class ArtifactRef {
        final String resourceId;
        final String artifactId;
        ArtifactRef(String resourceId, String artifactId) {
            this.resourceId = resourceId;
            this.artifactId = artifactId;
        }
    }
}
