#!/usr/bin/env node
"use strict";

const assert = require("node:assert");
const fs = require("node:fs/promises");
const path = require("node:path");
const { chromium } = require("playwright");
const {
  BASE_URL,
  UI_TIMEOUT_MS,
  cleanupDocuments,
  createTempDocument,
  ensureAuthenticated,
  openDocument,
  renameDocument,
  requestJson,
} = require("./tree-shell-smoke-helpers");

const OUT_DIR = path.join(process.cwd(), "tmp", "page-block-ai-tools-smoke");
const CHROME_EXECUTABLE = process.env.PLAYWRIGHT_CHROME_EXECUTABLE || "";

function blockById(blocks, blockId) {
  return blocks.find((block) => block.blockId === blockId);
}

function blockTexts(blocks) {
  return blocks.map((block) => block.text);
}

function aggregateBlocks(aggregate) {
  const blocks = aggregate?.body?.blockDocument?.blocks;
  assert(Array.isArray(blocks), "Page Aggregate 必须返回 body.blockDocument.blocks");
  return blocks;
}

function aggregateBlockById(aggregate, blockId) {
  return aggregateBlocks(aggregate).find((block) => block.blockId === blockId);
}

async function waitForVisibleTexts(page, expectedTexts) {
  await page.waitForFunction(
    ({ expected }) => {
      const visibleText = [];
      const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
      while (walker.nextNode()) {
        const node = walker.currentNode;
        const parent = node.parentElement;
        if (!(parent instanceof HTMLElement)) continue;
        const style = window.getComputedStyle(parent);
        const rect = parent.getBoundingClientRect();
        const text = (node.textContent || "").trim();
        if (
          text &&
          style.display !== "none" &&
          style.visibility !== "hidden" &&
          rect.width > 0 &&
          rect.height > 0
        ) {
          visibleText.push(text);
        }
      }
      return expected.every((text) => visibleText.some((visible) => visible.includes(text)));
    },
    { expected: expectedTexts },
    { timeout: UI_TIMEOUT_MS },
  );
}

async function callMnoteTool(request, payload) {
  return requestJson(request, "/api/hermes/tools/mnote/call", {
    method: "POST",
    headers: { "x-mnote-actor-id": payload.actorId || "smoke-user" },
    data: payload,
  });
}

async function fetchPageAggregate(request, workspaceId, documentId) {
  const payload = await requestJson(
    request,
    `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
    { method: "GET" },
  );
  assert.equal(payload.schema, "mnote.page_aggregate.v1", "Page Aggregate schema 应保持稳定");
  return payload.result;
}

async function fetchBlocks(request, target, suffix, label, actorId) {
  const response = await callMnoteTool(request, {
    toolName: "mnote.doc.fetch",
    workspaceId: target.workspaceId,
    documentId: target.documentId,
    actorId,
    sessionId: `sess_block_fetch_${suffix}`,
    runId: `run_block_fetch_${suffix}_${label}`,
    toolCallId: `call_block_fetch_${suffix}_${label}`,
    traceId: `trace_block_fetch_${suffix}_${label}`,
    capabilityScope: ["page.read"],
    args: {
      scope: "full",
      detail: "with_ids",
      maxBlocks: 20,
    },
  });
  assert.equal(response.ok, true, `${label}: doc.fetch 应成功`);
  assert(Array.isArray(response.result.blocks), `${label}: doc.fetch 应返回 blocks`);
  return response.result;
}

async function main() {
  const suffix = Date.now().toString(36);
  const title = `TEST-AI-BLOCK-TOOLS-${suffix}`;
  const createdIds = [];
  const evidence = {
    ok: false,
    baseUrl: BASE_URL,
    title,
    steps: [],
  };
  await fs.mkdir(OUT_DIR, { recursive: true });
  const hermesTesterDir = path.join(process.cwd(), "tmp", "hermes-tester", `page-block-ai-tools-${suffix}`);
  await fs.mkdir(hermesTesterDir, { recursive: true });

  const browser = await chromium.launch({
    headless: true,
    ...(CHROME_EXECUTABLE ? { executablePath: CHROME_EXECUTABLE } : {}),
    args: ["--no-sandbox", "--disable-dev-shm-usage"],
  });
  const context = await browser.newContext({ viewport: { width: 1280, height: 900 } });
  const page = await context.newPage();

  try {
    const viewer = await ensureAuthenticated(page, context.request);
    const actorId = viewer.userId;
    const target = await createTempDocument(context.request);
    createdIds.push(target.documentId);
    evidence.documentId = target.documentId;
    evidence.workspaceId = target.workspaceId;
    await renameDocument(context.request, target.workspaceId, target.documentId, title);

    const initialContent = [
      { id: "heading_1", type: "heading", props: { level: 1 }, content: [{ type: "text", text: `标题 ${suffix}` }] },
      { id: "p_1", type: "paragraph", content: [{ type: "text", text: `第一段 ${suffix}` }] },
      { id: "p_2", type: "paragraph", content: [{ type: "text", text: `第二段 ${suffix}` }] },
      { id: "p_3", type: "paragraph", content: [{ type: "text", text: `第三段 ${suffix}` }] },
      {
        id: "heading_parent",
        type: "heading",
        props: { level: 2 },
        content: [{ type: "text", text: `带子块标题 ${suffix}` }],
        children: [
          { id: "heading_child", type: "paragraph", content: [{ type: "text", text: `标题子段落 ${suffix}` }] },
        ],
      },
      { id: "list_item_1", type: "bullet_list_item", content: [{ type: "text", text: `列表项 ${suffix}` }] },
      { id: "table_1", type: "table", content: [{ type: "text", text: `表格块 ${suffix}` }] },
      {
        id: "mindmap_1",
        type: "mindmap",
        props: { mindmapId: `mindmap_smoke_${suffix}` },
        content: [{ type: "text", text: `思维导图块 ${suffix}` }],
      },
      { id: "resource_1", type: "resource", content: [{ type: "text", text: `资源块 ${suffix}` }] },
    ];
    const seed = await callMnoteTool(context.request, {
      toolName: "mnote.page.save",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_seed_${suffix}`,
      runId: `run_seed_${suffix}`,
      toolCallId: `call_seed_${suffix}`,
      traceId: `trace_seed_${suffix}`,
      idempotencyKey: `idem_seed_${suffix}`,
      dryRun: false,
      capabilityScope: ["page.write"],
      args: {
        mode: "replace",
        content: initialContent,
      },
    });
    assert.equal(seed.ok, true, "初始化 page.save 应成功");
    evidence.steps.push({ name: "seed", commandName: seed.result.commandName });

    let snapshot = await fetchBlocks(context.request, target, suffix, "initial", actorId);
    const initialAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
    await fs.writeFile(
      path.join(hermesTesterDir, "page-aggregate.json"),
      JSON.stringify(initialAggregate, null, 2),
      "utf8",
    );
    const p1 = blockById(snapshot.blocks, "p_1");
    const p2 = blockById(snapshot.blocks, "p_2");
    const p3 = blockById(snapshot.blocks, "p_3");
    const heading = blockById(snapshot.blocks, "heading_1");
    assert(heading && p1 && p2 && p3, "初始化后应能读取 heading_1/p_1/p_2/p_3");
    assert(p1.revisionRef && p2.revisionRef && p3.revisionRef, "块投影必须返回 revisionRef");
    const initialAggregateBlocks = aggregateBlocks(initialAggregate);
    assert.deepEqual(
      initialAggregateBlocks.map((block) => block.blockId),
      snapshot.blocks.map((block) => block.blockId),
      "Page Aggregate blockDocument 与 mnote.doc.fetch 块顺序必须一致",
    );
    assert(
      ["heading_1", "p_1", "p_2", "p_3", "list_item_1"].every((blockId) => {
        const block = aggregateBlockById(initialAggregate, blockId);
        return block?.editable === true && Boolean(block.revisionRef);
      }),
      "普通可编辑块必须在 Page Aggregate 中带 blockId/revisionRef",
    );
    const complexBlocks = ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]
      .map((blockId) => blockById(snapshot.blocks, blockId));
    assert(complexBlocks.every(Boolean), "初始化后应能读取复杂块投影");
    const unsupportedBlocks = ["table_1", "mindmap_1", "resource_1"].map((blockId) => blockById(snapshot.blocks, blockId));
    assert(
      unsupportedBlocks.every((block) => block.editable === false && block.unsupportedReason),
      "table/mindmap/resource 不能在 AI 投影中伪装成完全可编辑块",
    );
    assert.deepEqual(
      blockById(snapshot.blocks, "heading_parent").children,
      ["heading_child"],
      "带子块标题必须在投影中保留 children，用于阻断 move_after",
    );
    evidence.steps.push({
      name: "doc.fetch.initial",
      revision: snapshot.revision,
      conflictDetectionKey: snapshot.conflictDetectionKey,
      blockIds: snapshot.blocks.map((block) => block.blockId),
      aggregatePath: `/api/page-aggregate/${target.documentId}`,
      aggregateEvidencePath: path.join(hermesTesterDir, "page-aggregate.json"),
      aggregateBlockProjectionVersion: initialAggregate.body.blockProjectionVersion,
      aggregateProjectionSource: initialAggregate.body.projectionSource,
      aggregateBlockCount: initialAggregateBlocks.length,
      unsupportedBlocks: unsupportedBlocks.map((block) => ({
        blockId: block.blockId,
        type: block.type,
        editable: block.editable,
        unsupportedReason: block.unsupportedReason,
      })),
    });

    const outline = await callMnoteTool(context.request, {
      toolName: "mnote.doc.fetch",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_outline_${suffix}`,
      runId: `run_outline_${suffix}`,
      toolCallId: `call_outline_${suffix}`,
      traceId: `trace_outline_${suffix}`,
      capabilityScope: ["page.read"],
      args: {
        scope: "outline",
        detail: "with_ids",
      },
    });
    assert.equal(outline.ok, true, "doc.fetch outline 应成功");
    assert.deepEqual(
      outline.result.blocks.map((block) => block.blockId),
      ["heading_1", "heading_parent"],
      "outline 应只返回标题块",
    );

    const truncated = await callMnoteTool(context.request, {
      toolName: "mnote.doc.fetch",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_truncated_${suffix}`,
      runId: `run_truncated_${suffix}`,
      toolCallId: `call_truncated_${suffix}`,
      traceId: `trace_truncated_${suffix}`,
      capabilityScope: ["page.read"],
      args: {
        scope: "full",
        detail: "with_ids",
        maxBlocks: 2,
      },
    });
    assert.equal(truncated.result.truncated, true, "maxBlocks 裁剪应返回 truncated=true");
    assert.equal(truncated.result.blocks.length, 2, "maxBlocks=2 应只返回两个块");
    assert(truncated.result.continuation, "truncated 结果必须返回 continuation");

    const findP2 = await callMnoteTool(context.request, {
      toolName: "mnote.doc.find",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_find_${suffix}`,
      runId: `run_find_${suffix}`,
      toolCallId: `call_find_${suffix}`,
      traceId: `trace_find_${suffix}`,
      capabilityScope: ["page.read"],
      args: {
        query: `第二段 ${suffix}`,
      },
    });
    assert.equal(findP2.result.matches[0].blockId, "p_2", "doc.find 应定位第二段");
    await fs.writeFile(
      path.join(hermesTesterDir, "doc-fetch-find.json"),
      JSON.stringify(
        {
          full: snapshot,
          outline: outline.result,
          truncated: truncated.result,
          find: findP2.result,
        },
        null,
        2,
      ),
      "utf8",
    );
    evidence.steps.push({
      name: "doc.fetch.find",
      outlineBlockIds: outline.result.blocks.map((block) => block.blockId),
      truncated: truncated.result.truncated,
      findBlockId: findP2.result.matches[0].blockId,
      evidencePath: path.join(hermesTesterDir, "doc-fetch-find.json"),
    });

    const blockFetch = await callMnoteTool(context.request, {
      toolName: "mnote.block.fetch",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_block_${suffix}`,
      runId: `run_block_${suffix}`,
      toolCallId: `call_block_${suffix}`,
      traceId: `trace_block_${suffix}`,
      capabilityScope: ["block.read"],
      args: {
        blockId: findP2.result.matches[0].blockId,
        includeChildren: true,
        contextBefore: 1,
        contextAfter: 1,
      },
    });
    assert.equal(blockFetch.result.block.blockId, "p_2", "block.fetch 应读取目标块");
    assert(blockFetch.result.block.revisionRef, "block.fetch 应返回 revisionRef");
    assert.equal(blockFetch.result.context.before[0].blockId, "p_1", "block.fetch before 应来自同父级");
    assert.equal(blockFetch.result.context.after[0].blockId, "p_3", "block.fetch after 应来自同父级");
    evidence.steps.push({
      name: "block.fetch",
      blockId: "p_2",
      revisionRef: blockFetch.result.block.revisionRef,
      before: blockFetch.result.context.before.map((block) => block.blockId),
      after: blockFetch.result.context.after.map((block) => block.blockId),
    });

    const complexMoveBlocked = [];
    for (const blockId of ["heading_parent", "list_item_1", "table_1", "mindmap_1", "resource_1"]) {
      const complexBlock = blockById(snapshot.blocks, blockId);
      const plan = await callMnoteTool(context.request, {
        toolName: "mnote.doc.plan_update",
        workspaceId: target.workspaceId,
        documentId: target.documentId,
        actorId,
        sessionId: `sess_plan_complex_${suffix}`,
        runId: `run_plan_complex_${suffix}_${blockId}`,
        toolCallId: `call_plan_complex_${suffix}_${blockId}`,
        traceId: `trace_plan_complex_${suffix}_${blockId}`,
        idempotencyKey: `idem_plan_complex_${suffix}_${blockId}`,
        dryRun: true,
        capabilityScope: ["block.write"],
        args: {
          command: "block_move_after",
          blockId,
          anchorBlockId: "p_1",
        },
      });
      assert.equal(plan.result.blocked, true, `${blockId} move_after dry-run 必须阻断`);
      const blockedMove = await callMnoteTool(context.request, {
        toolName: "mnote.block.move_after",
        workspaceId: target.workspaceId,
        documentId: target.documentId,
        actorId,
        sessionId: `sess_move_complex_${suffix}`,
        runId: `run_move_complex_${suffix}_${blockId}`,
        toolCallId: `call_move_complex_${suffix}_${blockId}`,
        traceId: `trace_move_complex_${suffix}_${blockId}`,
        idempotencyKey: `idem_move_complex_${suffix}_${blockId}`,
        dryRun: false,
        capabilityScope: ["block.write"],
        args: {
          blockId,
          anchorBlockId: "p_1",
          revision: snapshot.revision,
          conflictDetectionKey: snapshot.conflictDetectionKey,
          blockRevisionRef: complexBlock.revisionRef,
          anchorRevisionRef: p1.revisionRef,
        },
      });
      assert.equal(blockedMove.result.blocked, true, `${blockId} 正式 move_after 必须返回 blocked`);
      assert.equal(
        blockedMove.result.warnings[0].code,
        "block_move_after_blocked",
        `${blockId} 正式 move_after 必须返回阻断 warning`,
      );
      complexMoveBlocked.push({
        blockId,
        type: complexBlock.type,
        editable: complexBlock.editable,
        dryRunBlocked: plan.result.blocked,
        writeBlocked: blockedMove.result.blocked,
        warningCode: blockedMove.result.warnings[0].code,
      });
    }
    const afterComplexBlocked = await fetchBlocks(context.request, target, suffix, "after_complex_blocked", actorId);
    assert.equal(afterComplexBlocked.revision, snapshot.revision, "复杂块 move_after 阻断后 revision 不应变化");
    assert.deepEqual(blockTexts(afterComplexBlocked.blocks), blockTexts(snapshot.blocks), "复杂块 move_after 阻断后正文不应变化");
    evidence.steps.push({
      name: "block.move_after.complex_blocked",
      blocked: complexMoveBlocked,
      revisionAfterBlocked: afterComplexBlocked.revision,
    });

    const dryRunBaselineTexts = blockTexts(snapshot.blocks);
    const dryRunBaselineAggregate = await fetchPageAggregate(
      context.request,
      target.workspaceId,
      target.documentId,
    );
    const replacePlan = await callMnoteTool(context.request, {
      toolName: "mnote.doc.plan_update",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_plan_replace_${suffix}`,
      runId: `run_plan_replace_${suffix}`,
      toolCallId: `call_plan_replace_${suffix}`,
      traceId: `trace_plan_replace_${suffix}`,
      idempotencyKey: `idem_plan_replace_${suffix}`,
      dryRun: true,
      capabilityScope: ["block.write"],
      args: {
        command: "block_replace",
        blockId: "p_2",
        content: `dry-run 替换 ${suffix}`,
      },
    });
    assert.equal(replacePlan.result.dryRun, true, "block_replace plan 必须是 dry-run");
    assert.equal(replacePlan.result.diff[0].before, `第二段 ${suffix}`, "replace plan 应解释 before");
    assert.equal(replacePlan.result.diff[0].after, `dry-run 替换 ${suffix}`, "replace plan 应解释 after");

    const insertPlan = await callMnoteTool(context.request, {
      toolName: "mnote.doc.plan_update",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_plan_insert_${suffix}`,
      runId: `run_plan_insert_${suffix}`,
      toolCallId: `call_plan_insert_${suffix}`,
      traceId: `trace_plan_insert_${suffix}`,
      idempotencyKey: `idem_plan_insert_${suffix}`,
      dryRun: true,
      capabilityScope: ["block.write"],
      args: {
        command: "block_insert_after",
        anchorBlockId: "p_1",
        content: `dry-run 插入 ${suffix}`,
      },
    });
    assert.equal(insertPlan.result.dryRun, true, "block_insert_after plan 必须是 dry-run");
    assert.equal(insertPlan.result.diff[0].after, `第一段 ${suffix}`, "insert plan 应解释 anchor after");

    const blockedPlan = await callMnoteTool(context.request, {
      toolName: "mnote.doc.plan_update",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_plan_blocked_${suffix}`,
      runId: `run_plan_blocked_${suffix}`,
      toolCallId: `call_plan_blocked_${suffix}`,
      traceId: `trace_plan_blocked_${suffix}`,
      idempotencyKey: `idem_plan_blocked_${suffix}`,
      dryRun: true,
      capabilityScope: ["block.write"],
      args: {
        command: "block_move_after",
        blockId: "p_3",
        anchorBlockId: "p_3",
      },
    });
    assert.equal(blockedPlan.result.blocked, true, "不支持 move 场景应返回 blocked=true");

    const dryRunAfterAggregate = await fetchPageAggregate(
      context.request,
      target.workspaceId,
      target.documentId,
    );
    const dryRunAfter = await fetchBlocks(context.request, target, suffix, "after_dry_run", actorId);
    assert.equal(dryRunAfter.revision, snapshot.revision, "dry-run 后 revision 不应变化");
    assert.deepEqual(blockTexts(dryRunAfter.blocks), dryRunBaselineTexts, "dry-run 后正文不应变化");
    assert.equal(
      dryRunAfterAggregate.body.conflictDetectionKey,
      dryRunBaselineAggregate.body.conflictDetectionKey,
      "dry-run 后 Page Aggregate conflictDetectionKey 不应变化",
    );
    evidence.steps.push({
      name: "plan_update.dry_run",
      replaceDiff: replacePlan.result.diff[0],
      insertDiff: insertPlan.result.diff[0],
      blocked: blockedPlan.result.blocked,
      revisionAfterDryRun: dryRunAfter.revision,
    });

    const replacedText = `第二段已替换 ${suffix}`;
    const replace = await callMnoteTool(context.request, {
      toolName: "mnote.block.replace",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_replace_${suffix}`,
      runId: `run_replace_${suffix}`,
      toolCallId: `call_replace_${suffix}`,
      traceId: `trace_replace_${suffix}`,
      idempotencyKey: `idem_replace_${suffix}`,
      dryRun: false,
      capabilityScope: ["block.write"],
      args: {
        blockId: "p_2",
        content: replacedText,
        revision: snapshot.revision,
        conflictDetectionKey: snapshot.conflictDetectionKey,
        blockRevisionRef: p2.revisionRef,
      },
    });
    assert.equal(replace.result.commandName, "page.body.save", "block.replace 必须走 page.body.save");
    snapshot = await fetchBlocks(context.request, target, suffix, "after_replace", actorId);
    const afterReplaceAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
    assert.equal(blockById(snapshot.blocks, "p_2").text, replacedText, "替换后 doc.fetch 应回读新文本");
    assert.equal(
      aggregateBlockById(afterReplaceAggregate, "p_2").text,
      replacedText,
      "替换后 Page Aggregate 必须回读新文本",
    );
    assert.equal(blockById(snapshot.blocks, "p_1").text, `第一段 ${suffix}`, "替换后相邻 p_1 不应变化");
    assert.equal(blockById(snapshot.blocks, "p_3").text, `第三段 ${suffix}`, "替换后相邻 p_3 不应变化");
    assert(blockById(snapshot.blocks, "p_2"), "替换后目标 block id 必须保持 p_2");
    evidence.steps.push({
      name: "block.replace",
      changedBlocks: replace.result.changedBlocks,
      targetBlockStillExists: Boolean(blockById(snapshot.blocks, "p_2")),
      aggregateRevision: afterReplaceAggregate.body.revision,
      aggregateConflictDetectionKey: afterReplaceAggregate.body.conflictDetectionKey,
      aggregateText: aggregateBlockById(afterReplaceAggregate, "p_2").text,
      adjacentTexts: [blockById(snapshot.blocks, "p_1").text, blockById(snapshot.blocks, "p_3").text],
    });

    const insertedText = `插入段 ${suffix}`;
    const anchorAfterReplace = blockById(snapshot.blocks, "p_1");
    const insert = await callMnoteTool(context.request, {
      toolName: "mnote.block.insert_after",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_insert_${suffix}`,
      runId: `run_insert_${suffix}`,
      toolCallId: `call_insert_${suffix}`,
      traceId: `trace_insert_${suffix}`,
      idempotencyKey: `idem_insert_${suffix}`,
      dryRun: false,
      capabilityScope: ["block.write"],
      args: {
        anchorBlockId: "p_1",
        content: {
          type: "todo",
          props: { checked: false },
          content: [{ type: "text", text: insertedText }],
        },
        revision: snapshot.revision,
        conflictDetectionKey: snapshot.conflictDetectionKey,
        anchorRevisionRef: anchorAfterReplace.revisionRef,
      },
    });
    const insertedBlockId = insert.result.changedBlocks[0].blockId;
    assert(insertedBlockId.startsWith("ai_block_"), "插入块 id 必须由 Rust/mnote 侧生成");
    snapshot = await fetchBlocks(context.request, target, suffix, "after_insert", actorId);
    assert.equal(blockById(snapshot.blocks, insertedBlockId).text, insertedText, "插入后 doc.fetch 应回读新块");
    const orderAfterInsert = snapshot.blocks.map((block) => block.blockId);
    assert.equal(
      orderAfterInsert.indexOf(insertedBlockId),
      orderAfterInsert.indexOf("p_1") + 1,
      "插入块必须紧跟 p_1",
    );
    const insertedBlockFetch = await callMnoteTool(context.request, {
      toolName: "mnote.block.fetch",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_insert_fetch_${suffix}`,
      runId: `run_insert_fetch_${suffix}`,
      toolCallId: `call_insert_fetch_${suffix}`,
      traceId: `trace_insert_fetch_${suffix}`,
      capabilityScope: ["block.read"],
      args: {
        blockId: insertedBlockId,
        contextBefore: 1,
        contextAfter: 1,
      },
    });
    assert.equal(insertedBlockFetch.result.block.blockId, insertedBlockId, "block.fetch 应能读取新插入块");
    assert.equal(insertedBlockFetch.result.block.type, "todo", "insert_after 应能插入 todo 块");
    const replayInsertText = `不应重复插入 ${suffix}`;
    const insertReplay = await callMnoteTool(context.request, {
      toolName: "mnote.block.insert_after",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_insert_replay_${suffix}`,
      runId: `run_insert_replay_${suffix}`,
      toolCallId: `call_insert_replay_${suffix}`,
      traceId: `trace_insert_replay_${suffix}`,
      idempotencyKey: `idem_insert_${suffix}`,
      dryRun: false,
      capabilityScope: ["block.write"],
      args: {
        anchorBlockId: "p_1",
        content: replayInsertText,
        revision: snapshot.revision,
        conflictDetectionKey: snapshot.conflictDetectionKey,
        anchorRevisionRef: blockById(snapshot.blocks, "p_1").revisionRef,
      },
    });
    assert.equal(
      insertReplay.audit.commandId,
      insert.audit.commandId,
      "重复 insert_after idempotencyKey 应返回缓存 commandId",
    );
    const afterInsertReplay = await fetchBlocks(context.request, target, suffix, "after_insert_replay", actorId);
    assert.equal(
      afterInsertReplay.blocks.filter((block) => block.blockId === insertedBlockId).length,
      1,
      "重复 insert_after idempotencyKey 不应产生第二个相同插入块",
    );
    assert(!afterInsertReplay.blocks.some((block) => block.text === replayInsertText), "重复 insert_after 不应写入新内容");
    assert.equal(afterInsertReplay.revision, snapshot.revision, "重复 insert_after 后 revision 不应再次递增");
    snapshot = afterInsertReplay;
    evidence.steps.push({
      name: "block.insert_after",
      insertedBlockId,
      insertedBlockType: insertedBlockFetch.result.block.type,
      orderAfterInsert,
      replayCommandId: insertReplay.audit.commandId,
      revisionAfterReplay: snapshot.revision,
    });

    const moveBlock = blockById(snapshot.blocks, "p_3");
    const moveAnchor = blockById(snapshot.blocks, "p_1");
    const moveDryRun = await callMnoteTool(context.request, {
      toolName: "mnote.doc.plan_update",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_plan_${suffix}`,
      runId: `run_plan_${suffix}`,
      toolCallId: `call_plan_${suffix}`,
      traceId: `trace_plan_${suffix}`,
      idempotencyKey: `idem_plan_${suffix}`,
      dryRun: true,
      capabilityScope: ["block.write"],
      args: {
        command: "block_move_after",
        blockId: "p_3",
        anchorBlockId: "p_1",
      },
    });
    assert.equal(moveDryRun.result.blocked, false, "同父级普通叶子块 move dry-run 不应阻断");
    const move = await callMnoteTool(context.request, {
      toolName: "mnote.block.move_after",
      workspaceId: target.workspaceId,
      documentId: target.documentId,
      actorId,
      sessionId: `sess_move_${suffix}`,
      runId: `run_move_${suffix}`,
      toolCallId: `call_move_${suffix}`,
      traceId: `trace_move_${suffix}`,
      idempotencyKey: `idem_move_${suffix}`,
      dryRun: false,
      capabilityScope: ["block.write"],
      args: {
        blockId: "p_3",
        anchorBlockId: "p_1",
        revision: snapshot.revision,
        conflictDetectionKey: snapshot.conflictDetectionKey,
        blockRevisionRef: moveBlock.revisionRef,
        anchorRevisionRef: moveAnchor.revisionRef,
      },
    });
    assert.equal(move.result.changedBlocks[0].op, "move_after", "move_after 应返回 changedBlocks");
    snapshot = await fetchBlocks(context.request, target, suffix, "after_move", actorId);
    const order = snapshot.blocks.map((block) => block.blockId);
    assert(order.indexOf("p_3") === order.indexOf("p_1") + 1, "移动后 p_3 必须紧跟 p_1");
    assert(blockById(snapshot.blocks, "p_3"), "移动后 moving block id 必须保持 p_3");
    evidence.steps.push({
      name: "block.move_after",
      dryRunBlocked: moveDryRun.result.blocked,
      dryRunDiff: moveDryRun.result.diff[0],
      order,
      texts: blockTexts(snapshot.blocks),
      movingBlockStillExists: Boolean(blockById(snapshot.blocks, "p_3")),
    });

    await openDocument(page, target.workspaceId, target.documentId);
    await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
    await page.reload({ waitUntil: "domcontentloaded" });
    await waitForVisibleTexts(page, [replacedText, insertedText, `第三段 ${suffix}`]);
    const afterRefreshAggregate = await fetchPageAggregate(context.request, target.workspaceId, target.documentId);
    assert.deepEqual(
      aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId),
      snapshot.blocks.map((block) => block.blockId),
      "页面刷新后 Page Aggregate block ids 必须保持稳定",
    );
    const screenshotPath = path.join(OUT_DIR, `${suffix}-page.png`);
    await page.screenshot({ path: screenshotPath, fullPage: true });
    evidence.screenshot = screenshotPath;
    evidence.visibleTextCheck = [replacedText, insertedText, `第三段 ${suffix}`];
    evidence.afterRefreshBlockIds = aggregateBlocks(afterRefreshAggregate).map((block) => block.blockId);
    evidence.finalTexts = blockTexts(snapshot.blocks);
    evidence.ok = true;

    const evidencePath = path.join(OUT_DIR, `${suffix}.json`);
    await fs.writeFile(evidencePath, JSON.stringify(evidence, null, 2), "utf8");
    console.log(JSON.stringify({ ...evidence, evidencePath }, null, 2));
  } finally {
    await cleanupDocuments(context.request, createdIds).catch(() => undefined);
    await context.close().catch(() => undefined);
    await browser.close().catch(() => undefined);
  }
}

main().catch((error) => {
  console.error(error instanceof Error ? error.stack || error.message : String(error));
  process.exit(1);
});
