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

const assert = require("node:assert");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { chromium } = require("playwright");

const BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
  || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium"]
    .find((candidate) => fs.existsSync(candidate));

function fileUrl(localPath) {
  return `file://${localPath}`;
}

function localMdDocumentId(relativePath) {
  return `local-md:${relativePath.replaceAll("/", "~2F")}`;
}

function documentUrl(root, relativePath) {
  const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(localMdDocumentId(relativePath))}`);
  url.searchParams.set("sourceKind", "local_folder");
  url.searchParams.set("rootUri", fileUrl(root));
  url.searchParams.set("treeView", "filetree");
  return url.toString();
}

function localFolderHomeUrl(root) {
  const url = new URL(`${BASE_URL}/`);
  url.searchParams.set("sourceKind", "local_folder");
  url.searchParams.set("rootUri", fileUrl(root));
  url.searchParams.set("treeView", "filetree");
  return url.toString();
}

function writeWorkspaceManifest(root, ownerId, taskId) {
  const metadataDir = path.join(root, ".mnote");
  fs.mkdirSync(metadataDir, { recursive: true });
  fs.writeFileSync(
    path.join(metadataDir, "workspace.json"),
    `${JSON.stringify({
      workspaceId: `local-ws:${ownerId}:${taskId}`,
      ownerId,
      createdAt: new Date().toISOString(),
      capabilities: ["local_files", "markdown_edit", "asset_upload"],
    }, null, 2)}\n`,
    "utf8",
  );
}

async function quickLogin(page) {
  await page.goto(`${BASE_URL}/auth`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  const quickLoginButton = page.getByRole("button", { name: "测试账号快速登录" });
  if (await quickLoginButton.count()) {
    await quickLoginButton.click({ timeout: UI_TIMEOUT_MS });
    await page.waitForURL((url) => url.pathname === "/", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
  }
}

async function activatePrimaryPageTab(page) {
  const pageTab = page.locator('[data-mnote-main-tab="page"][data-pane-role="primary"]').first();
  if (await pageTab.count()) {
    await pageTab.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
  }
  await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
    state: "visible",
    timeout: UI_TIMEOUT_MS,
  });
}

async function uploadAttachmentViaPrimarySlash(page, fileName, markdownContent) {
  await activatePrimaryPageTab(page);
  const editor = page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first();
  await editor.click({ timeout: UI_TIMEOUT_MS });
  await page.keyboard.press("End").catch(() => undefined);
  await page.keyboard.type("/");
  const item = page
    .locator('.document-pane[data-pane-role="primary"] [data-testid="slash-item-upload-attachment"]')
    .first();
  await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  const [fileChooser] = await Promise.all([
    page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
    item.click({ timeout: UI_TIMEOUT_MS }),
  ]);
  await fileChooser.setFiles({
    name: fileName,
    mimeType: "text/markdown",
    buffer: Buffer.from(markdownContent, "utf8"),
  });
  await page.waitForFunction(
    (name) => {
      const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
      return editor instanceof HTMLElement && (editor.textContent || "").includes(name);
    },
    fileName,
    { timeout: UI_TIMEOUT_MS },
  );
}

async function waitForFileExists(filePath, timeoutMs) {
  const startedAt = Date.now();
  while (!fs.existsSync(filePath) && Date.now() - startedAt < timeoutMs) {
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  return fs.existsSync(filePath);
}

async function waitForUploadAsset(uploadResponses, startIndex, predicate, timeoutMs) {
  const startedAt = Date.now();
  let seen = [];
  while (Date.now() - startedAt < timeoutMs) {
    seen = uploadResponses.slice(startIndex);
    for (const entry of seen) {
      const asset = entry?.payload?.asset;
      if (asset && predicate(asset, entry)) return asset;
    }
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  throw new Error(`未捕获到符合条件的上传响应: ${JSON.stringify(seen).slice(0, 1000)}`);
}

async function waitForFileContent(filePath, predicate, timeoutMs) {
  const startedAt = Date.now();
  let lastContent = "";
  while (Date.now() - startedAt < timeoutMs) {
    if (fs.existsSync(filePath)) {
      lastContent = fs.readFileSync(filePath, "utf8");
      if (predicate(lastContent)) return { ok: true, content: lastContent };
    }
    await new Promise((resolve) => setTimeout(resolve, 150));
  }
  return { ok: false, content: lastContent };
}

async function assertNoPrimaryConflictPanel(page, label) {
  await page.waitForTimeout(1000);
  const conflict = await page.evaluate(() => {
    const pane = document.querySelector('.document-pane[data-pane-role="primary"]');
    const panel = pane?.querySelector('[data-testid="mnote-editor-conflict-panel"]') || null;
    const status = pane?.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status')
      || document.querySelector('[data-runtime-editor-status]')?.getAttribute('data-runtime-editor-status')
      || "";
    return {
      visible: panel instanceof HTMLElement && panel.offsetParent !== null,
      text: panel instanceof HTMLElement ? panel.textContent || "" : "",
      status,
    };
  });
  assert(
    !conflict.visible && conflict.status !== "external-change-conflict",
    `${label} 不应出现文件冲突: ${JSON.stringify(conflict)}`,
  );
}

async function waitForPrimaryAttachment(page, fileName) {
  const link = page
    .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]', {
      hasText: fileName,
    })
    .first();
  await link.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await page.waitForFunction(
    (name) => {
      const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
      const link = Array.from(editor?.querySelectorAll('a[href*="/api/local-folder/files/open"]') || [])
        .find((node) => node instanceof HTMLAnchorElement && (node.textContent || "").includes(name));
      return link instanceof HTMLAnchorElement
        && (link.classList.contains("mnote-uploaded-attachment-row") || getComputedStyle(link).display === "inline-flex");
    },
    fileName,
    { timeout: UI_TIMEOUT_MS },
  );
  return link;
}

async function selectPrimaryAttachmentLink(page, fileName) {
  await page.evaluate((name) => {
    const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
    if (!(editor instanceof HTMLElement)) throw new Error("editor_missing");
    const link = Array.from(editor.querySelectorAll("a")).find((node) => (
      node instanceof HTMLAnchorElement && (node.textContent || "").includes(name)
    ));
    if (!(link instanceof HTMLAnchorElement)) throw new Error(`attachment_link_missing:${name}`);
    const range = document.createRange();
    range.selectNode(link);
    const selection = window.getSelection();
    if (!selection) throw new Error("selection_missing");
    selection.removeAllRanges();
    selection.addRange(range);
    editor.focus();
  }, fileName);
}

async function deleteAttachmentByKeyboard(page, fileName) {
  await waitForPrimaryAttachment(page, fileName);
  await selectPrimaryAttachmentLink(page, fileName);
  await page.keyboard.press("Backspace");
}

async function deleteAttachmentByBlockHandle(page, fileName) {
  const link = await waitForPrimaryAttachment(page, fileName);
  const box = await link.boundingBox();
  assert(box, `附件 ${fileName} 缺少可点击区域`);
  await page.mouse.move(box.x + Math.min(12, box.width / 2), box.y + box.height / 2, { steps: 8 });
  const trigger = page.locator('[data-testid="block-drag-handle-trigger"]').first();
  await trigger.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await trigger.click({ timeout: UI_TIMEOUT_MS });
  const menu = page.locator('[data-testid="block-drag-menu"]').first();
  await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await page.locator('[data-testid="block-drag-menu-item-delete"]').first().click({ timeout: UI_TIMEOUT_MS });
}

function writeTwoAttachmentPage(root, relativePath, resourceDirName, heading) {
  const resourceDir = path.join(root, resourceDirName);
  fs.mkdirSync(resourceDir, { recursive: true });
  fs.writeFileSync(path.join(resourceDir, "first.md"), "# First\n\n第一个附件\n", "utf8");
  fs.writeFileSync(path.join(resourceDir, "second.md"), "# Second\n\n第二个附件\n", "utf8");
  fs.writeFileSync(
    path.join(root, relativePath),
    [
      `# ${heading}`,
      "",
      `[first.md](${resourceDirName}/first.md)`,
      "",
      `[second.md](${resourceDirName}/second.md)`,
      "",
    ].join("\n"),
    "utf8",
  );
}

// ─── main ────────────────────────────────────────────────────────────────────

async function main() {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task479-lifecycle-"));
  const relativePath = "README.md";
  const resourceDirName = "README";
  const resourceDir = path.join(root, resourceDirName);
  fs.mkdirSync(resourceDir, { recursive: true });
  writeWorkspaceManifest(root, "user_real", "task479");

  // 主正文：README.md，引用一个已存在的附件 resource-note.md
  fs.writeFileSync(
    path.join(root, relativePath),
    [
      "---",
      "title: Lifecycle Smoke",
      "---",
      "",
      "# Lifecycle Smoke",
      "",
      "正文内容不变。",
      "",
      "[resource-note.md](" + resourceDirName + "/resource-note.md)",
      "",
    ].join("\n"),
    "utf8",
  );
  fs.writeFileSync(path.join(root, resourceDirName, "resource-note.md"), "# Resource Note\n\n已有资源正文\n", "utf8");

  // 为 Check 6 准备一个带 H1 的文件（文件名不含空格，避免空格编码干扰测试）
  fs.writeFileSync(path.join(root, "MyNotes.md"), "# Body Heading\n\n正文内容\n", "utf8");

  // 为 Check 7 准备两个删除附件引用的独立页面，避免不同删除路径互相污染。
  writeTwoAttachmentPage(root, "DeleteKeyboard.md", "DeleteKeyboard", "Delete Keyboard");
  writeTwoAttachmentPage(root, "DeleteHandle.md", "DeleteHandle", "Delete Handle");

  // 为 Check 5 准备足够的文件树条目来产生可滚动区域
  fs.mkdirSync(path.join(root, "docs"), { recursive: true });
  for (let i = 0; i < 30; i++) {
    fs.writeFileSync(path.join(root, "docs", `doc-${String(i).padStart(2, "0")}.md`), `# Doc ${i}\n`, "utf8");
  }
  // 为 Check 5 准备的附件 PDF
  fs.writeFileSync(path.join(resourceDir, "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 report-a\n", "utf8"));

  const browser = await chromium.launch({
    headless: process.env.HEADFUL !== "1",
    executablePath: CHROMIUM_EXECUTABLE_PATH,
  });
  const context = await browser.newContext({
    viewport: { width: 1360, height: 900 },
    extraHTTPHeaders: {
      "x-mnote-actor-id": "user_real",
      "x-mnote-actor-type": "user",
    },
  });
  const page = await context.newPage();
  const popups = [];
  page.on("popup", (popup) => popups.push(popup));
  const uploadResponses = [];
  page.on("response", async (response) => {
    if (!response.url().includes("/api/local-folder/assets/upload")) return;
    const payload = await response.json().catch(() => null);
    uploadResponses.push({ status: response.status(), payload });
  });

  const screenshots = [];
  const checks = {};
  let overallOk = true;

  try {
    // ── Login ──
    await quickLogin(page);

    // ──────────────────────────────────────────────────────────────────────────
    // Check 1: 主编辑区上传 / drop 附件后，附件应进入当前 Markdown 页面的资源目录
    //          （README/uploaded-one.md 不是 root 同级）
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "editor-upload-to-page-resource-dir";
      try {
        await page.goto(documentUrl(root, relativePath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });

        const uploadStart = uploadResponses.length;
        // 通过主编辑区斜杠上传上传一个 MD 文件
        await uploadAttachmentViaPrimarySlash(page, "uploaded-one.md", "# Uploaded One\n\n第一个上传附件\n");
        const uploadAsset = await waitForUploadAsset(
          uploadResponses,
          uploadStart,
          (asset) => asset.file_name === "uploaded-one.md",
          UI_TIMEOUT_MS,
        );

        // 等待后端 write_local_markdown_asset() 落盘
        const correctPath = path.join(resourceDir, "uploaded-one.md");
        const wrongPath = path.join(root, "uploaded-one.md");
        const landedInResourceDir = await waitForFileExists(correctPath, UI_TIMEOUT_MS);
        const landedInRoot = fs.existsSync(wrongPath);

        assert(
          landedInResourceDir,
          `上传文件应落在 page resource directory (${correctPath})。`
          + ` landedInRoot=${landedInRoot} correctPath=${correctPath}`,
        );
        assert.equal(uploadAsset.uploadIntent, "editor.markdown.attach", "主编辑区上传响应应返回 editor.markdown.attach intent");
        assert.equal(uploadAsset.rootRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 page resource rootRelativePath");
        assert.equal(uploadAsset.markdownRelativePath, "README/uploaded-one.md", "主编辑区上传响应应返回 markdownRelativePath");
        assert.equal(uploadAsset.ownerDocumentId, "local-md:README.md", "主编辑区上传响应应返回 ownerDocumentId");
        await assertNoPrimaryConflictPanel(page, "主编辑区上传附件后");

        checks[checkId] = {
          ok: true,
          message: `上传文件落在资源目录 ${correctPath}`,
          landedInResourceDir,
          landedInRoot,
          uploadAsset,
        };
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `主编辑区上传落盘目录检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 2: 文件树 folder row 外部 drop 到目标文件夹时，文件应进入该目标 folder
    //          而不是当前 Markdown 页面的资源目录
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "filetree-folder-drop-to-target";
      try {
        await page.goto(documentUrl(root, relativePath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        // 等待文件树就绪
        const docsFolderRow = page.locator(
          '#sidebar-file-tree-root .tree-row[data-row-id="local:folder:docs"]',
        ).first();
        await docsFolderRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });

        const droppedFileName = "drag-dropped-note.md";
        const droppedContent = "# Drag Dropped\n\n从外部拖入\n";
        const uploadStart = uploadResponses.length;

        // 使用 dispatchEvent 模拟外部文件拖入 docs 文件夹
        await docsFolderRow.dispatchEvent("dragover", {
          dataTransfer: await page.evaluateHandle(
            ({ fileName, fileContent }) => {
              const dt = new DataTransfer();
              dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
              return dt;
            },
            { fileName: droppedFileName, fileContent: droppedContent },
          ),
        });
        await docsFolderRow.dispatchEvent("drop", {
          dataTransfer: await page.evaluateHandle(
            ({ fileName, fileContent }) => {
              const dt = new DataTransfer();
              dt.items.add(new File([fileContent], fileName, { type: "text/markdown" }));
              return dt;
            },
            { fileName: droppedFileName, fileContent: droppedContent },
          ),
        });

        // 等待文件落盘
        const droppedPath = path.join(root, "docs", droppedFileName);
        const exists = await waitForFileExists(droppedPath, UI_TIMEOUT_MS);
        const uploadAsset = await waitForUploadAsset(
          uploadResponses,
          uploadStart,
          (asset) => asset.file_name === droppedFileName,
          UI_TIMEOUT_MS,
        );
        assert(exists, `拖入 docs 文件夹后文件应出现在 ${droppedPath}`);
        assert.equal(uploadAsset.uploadIntent, "filetree.folder.drop", "文件树 folder drop 响应应返回 filetree.folder.drop intent");
        assert.equal(uploadAsset.rootRelativePath, "docs/drag-dropped-note.md", "文件树 folder drop 响应应返回目标目录 rootRelativePath");
        assert.equal(uploadAsset.targetRelativePath, "docs", "文件树 folder drop 响应应返回 targetRelativePath");
        assert.equal(uploadAsset.markdownRelativePath, null, "文件树 folder drop 不应返回 markdownRelativePath");
        assert.equal(uploadAsset.ownerDocumentId, null, "文件树 folder drop 不应返回 ownerDocumentId");

        // 确保没有出现在 root 或 README/ 下
        const wrongPath = path.join(root, droppedFileName);
        const wrongResourcePath = path.join(resourceDir, droppedFileName);
        assert(
          !fs.existsSync(wrongPath) && !fs.existsSync(wrongResourcePath),
          `拖入 docs 文件夹的文件不应落在 root (${wrongPath}) 或 resourceDir (${wrongResourcePath}) 下`,
        );
        await assertNoPrimaryConflictPanel(page, "文件树文件夹拖入文件后");

        checks[checkId] = {
          ok: true,
          message: `外部拖入文件进入目标文件夹 docs/`,
          droppedPath,
          exists,
          uploadAsset,
        };
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `文件树 folder drop 检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 3: 删除真实附件文件后，刷新页面，正文中的 Markdown 链接仍应可见
    //          如果当前实现失败，先记录当前 failure，但断言应表达预期行为
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "broken-link-after-real-file-delete";
      try {
        await page.goto(documentUrl(root, relativePath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });

        // 先确认正文中有附件链接
        const linkTextBefore = await page.evaluate(() => {
          const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
          return editor instanceof HTMLElement ? editor.textContent || "" : "";
        });
        assert(
          linkTextBefore.includes("resource-note.md"),
          `初始正文应包含附件链接 resource-note.md，实际内容: ${linkTextBefore.slice(0, 500)}`,
        );

        // 从磁盘删除真实附件文件
        const assetPath = path.join(resourceDir, "resource-note.md");
        assert(fs.existsSync(assetPath), "测试前置条件：附件文件应存在");
        fs.unlinkSync(assetPath);
        await page.waitForFunction(
          () => {
            const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
              .find((node) => (node.textContent || "").includes("resource-note.md"));
            return link instanceof HTMLAnchorElement && link.getAttribute("data-mnote-attachment-missing") === "true";
          },
          null,
          { timeout: UI_TIMEOUT_MS },
        );
        const missingMarkedBeforeReload = await page.evaluate(() => {
          const link = Array.from(document.querySelectorAll('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a[href*="/api/local-folder/files/open"]'))
            .find((node) => (node.textContent || "").includes("resource-note.md"));
          return link instanceof HTMLAnchorElement
            ? {
                missing: link.getAttribute("data-mnote-attachment-missing"),
                text: link.textContent || "",
              }
            : null;
        });
        const markdownAfterDeleteBeforeReload = fs.readFileSync(path.join(root, relativePath), "utf8");
        assert(markdownAfterDeleteBeforeReload.includes("[resource-note.md](README/resource-note.md)"), "watcher 标记 missing 不应改写 Markdown 正文");

        // 刷新页面
        await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        // 给异步解析和渲染留时间
        await page.waitForTimeout(1000);

        // 检查正文是否仍包含附件链接文本
        const linkTextAfter = await page.evaluate(() => {
          const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
          return editor instanceof HTMLElement ? editor.textContent || "" : "";
        });

        // 预期行为：链接应保留
        const linkPreserved = linkTextAfter.includes("resource-note.md");
        const popupCountBefore = popups.length;
        const brokenLink = page
          .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', {
            hasText: "resource-note.md",
          })
          .first();
        await brokenLink.click({ timeout: UI_TIMEOUT_MS });
        await page.waitForTimeout(1500);
        const popupCountAfter = popups.length;
        const resourceTabErrorVisible = await page
          .locator('[data-testid="mnote-resource-tab-host"]:not([hidden]) .mnote-resource-tab-error, [data-mnote-resource-panel][data-resource-kind="error"]')
          .first()
          .isVisible()
          .catch(() => false);
        const resourceTabTitle = await page
          .locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "resource-note.md" })
          .first()
          .textContent({ timeout: UI_TIMEOUT_MS })
          .catch(() => "");
        assert.equal(popupCountAfter, popupCountBefore, "点击缺失附件不应打开新浏览器窗口");
        assert(resourceTabErrorVisible, "点击缺失附件应进入 resource tab missing/error state");
        assert((resourceTabTitle || "").includes("resource-note.md"), `缺失资源 tab 标题应包含 resource-note.md，实际: ${resourceTabTitle}`);

        checks[checkId] = {
          ok: linkPreserved,
          message: linkPreserved
            ? "删除真实附件后刷新，正文链接仍保留；点击缺失链接进入 resource tab missing/error state"
            : "删除真实附件后刷新，正文链接消失（当前实现未保留 broken link）",
          assetPath,
          deleted: true,
          linkPreserved,
          missingMarkedBeforeReload,
          editorText: linkTextAfter.slice(0, 500),
          popupCount: popupCountAfter - popupCountBefore,
          resourceTabErrorVisible,
          resourceTabTitle,
        };

        if (!linkPreserved) {
          overallOk = false;
        }
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `删除真实附件后正文链接保留检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 4: 刚进入 local folder、未先打开任何 Markdown 文件时，
    //          点击 PDF / Markdown 附件 row 应在主编辑区 tab 打开，不应产生 popup。
    //          若当前 UI 无法直接进入这种状态，记录原因并把该 check 标记为 skipped
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "direct-attach-open-without-active-md";
      try {
        // 使用独立 asset-only 工作区，确保 local folder 首屏没有 active Markdown document。
        const assetOnlyRoot = path.join(root, "asset-only-workspace");
        fs.mkdirSync(path.join(assetOnlyRoot, "attachments"), { recursive: true });
        writeWorkspaceManifest(assetOnlyRoot, "user_real", "task479-asset-only");
        fs.writeFileSync(path.join(assetOnlyRoot, "attachments", "report-a.pdf"), Buffer.from("%PDF-1.4\n% task479 asset-only report-a\n", "utf8"));

        const homeUrl = localFolderHomeUrl(assetOnlyRoot);
        await page.goto(homeUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });

        // 等待文件树就绪
        const fileTreeRoot = page.locator("#sidebar-file-tree-root").first();
        await fileTreeRoot.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });

        const hasResourceHost = await page.locator('[data-mnote-resource-tab-host][data-pane-role="primary"]')
          .first()
          .count()
          .then((count) => count > 0)
          .catch(() => false);

        const resourceFolderRow = page
          .locator('#sidebar-file-tree-root .tree-row[data-node-id="local:node:attachments"], #sidebar-file-tree-root .tree-row[data-row-id="local:folder:attachments"]')
          .first();
        if (await resourceFolderRow.isVisible().catch(() => false)) {
          const expanded = await resourceFolderRow.getAttribute("aria-expanded").catch(() => null);
          const resourceFolderToggle = resourceFolderRow.locator('[data-testid="filetree-toggle"]').first();
          if (expanded !== "true" && await resourceFolderToggle.isVisible().catch(() => false)) {
            await resourceFolderToggle.click({ timeout: UI_TIMEOUT_MS }).catch(() => undefined);
          }
        }

        // 找到附件 row (data-testid="filetree-asset-row" 或 data-row-kind="asset"/"attachment")
        const pdfAssetRow = page.locator('[data-testid="filetree-asset-row"]', {
          hasText: "report-a.pdf",
        }).first();
        await pdfAssetRow.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
        const hasPdfRow = await pdfAssetRow.isVisible().catch(() => false);

        assert(hasResourceHost, "无 active Markdown document 时应渲染主编辑区 resource tab host");
        assert(hasPdfRow, "无 active Markdown document 时应渲染 report-a.pdf 附件 row");

        const popupCountBefore = popups.length;
        await pdfAssetRow.locator(".tree-link").click({ timeout: UI_TIMEOUT_MS });
        await page.waitForTimeout(2000);

        const popupCountAfter = popups.length;
        const resourceTabVisible = await page.locator('[data-testid="mnote-resource-tab-host"]:not([hidden])')
          .isVisible().catch(() => false);
        const resourceTabTitle = await page
          .locator('[data-mnote-main-tab][data-pane-role="primary"] .mnote-main-tab-title', { hasText: "report-a.pdf" })
          .first()
          .textContent({ timeout: UI_TIMEOUT_MS })
          .catch(() => "");
        const rowState = await pdfAssetRow.evaluate((row) => ({
          rowId: row.getAttribute("data-row-id") || "",
          selected: row.getAttribute("data-selected") || "",
          active: row.getAttribute("data-active") || "",
          focused: row.getAttribute("data-focused") || "",
        }));
        const selectedRows = await page.locator('#sidebar-file-tree-root .tree-row[data-selected="true"]').evaluateAll((rows) => (
          rows.map((row) => ({
            rowId: row.getAttribute("data-row-id") || "",
            text: row.textContent || "",
          }))
        ));
        const activeRows = await page.locator('#sidebar-file-tree-root .tree-row[data-active="true"]').evaluateAll((rows) => (
          rows.map((row) => ({
            rowId: row.getAttribute("data-row-id") || "",
            text: row.textContent || "",
          }))
        ));

        assert(
          popupCountAfter === popupCountBefore,
          `点击附件 row 不应产生新浏览器窗口/标签页，popup 数：${popupCountBefore} → ${popupCountAfter}`,
        );
        assert(resourceTabVisible, "点击附件 row 后应显示主编辑区 resource tab host");
        assert((resourceTabTitle || "").includes("report-a.pdf"), `资源 tab 标题应包含 report-a.pdf，实际: ${resourceTabTitle}`);
        assert.equal(rowState.selected, "true", `点击附件 row 后应保持选中该 row，实际: ${JSON.stringify({ rowState, selectedRows })}`);
        assert.equal(rowState.active, "true", `点击附件 row 后应保持 active 在该 row，实际: ${JSON.stringify({ rowState, activeRows })}`);
        assert.deepEqual(selectedRows.map((row) => row.rowId), [rowState.rowId], `打开资源后不应跳选其他 row: ${JSON.stringify(selectedRows)}`);
        assert.deepEqual(activeRows.map((row) => row.rowId), [rowState.rowId], `打开资源后 active row 不应跳到其他 row: ${JSON.stringify(activeRows)}`);

        checks[checkId] = {
          ok: true,
          message: "无 active Markdown document 时，点击附件 row 后主编辑区 resource tab 打开，且 File Tree active/selected 保持在该附件 row",
          popupCount: popupCountAfter - popupCountBefore,
          resourceTabVisible,
          resourceTabTitle,
          rowState,
          selectedRows,
          activeRows,
        };
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `直接打开附件检查异常: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 5: 文件树滚动到底和 resize handle 上滚轮后，
    //          document.scrollingElement.scrollTop 应保持 0 或不产生底部空白
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "filetree-scroll-no-body-scroll";
      try {
        await page.goto(documentUrl(root, relativePath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator("#sidebar-file-tree-root").waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        // 先让文件树出现
        await page.waitForTimeout(500);

        // 获取滚动前的 body scrollTop
        const scrollBefore = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);

        // 尝试让文件树滚动到底：在文件树上用鼠标滚轮
        const fileTree = page.locator("#sidebar-file-tree-root").first();
        await fileTree.hover({ timeout: UI_TIMEOUT_MS });

        // 多次在文件树上滚轮模拟快速滚到底
        for (let i = 0; i < 30; i++) {
          await page.mouse.wheel(0, 120);
          await new Promise((r) => setTimeout(r, 30));
        }

        // 再在文件树区域一次性大量滚轮
        for (let i = 0; i < 50; i++) {
          await page.mouse.wheel(0, 300);
          await new Promise((r) => setTimeout(r, 20));
        }

        await page.waitForTimeout(500);

        const scrollAfter = await page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);

        // 预期：body scrollTop 不应被文件树滚轮事件改变
        const bodyNotScrolled = scrollAfter <= scrollBefore;

        checks[checkId] = {
          ok: bodyNotScrolled,
          message: bodyNotScrolled
            ? "文件树内滚轮没有导致 body 滚动"
            : "文件树滚轮导致 body 产生了滚动（出现底部空白）",
          scrollBefore,
          scrollAfter,
          delta: scrollAfter - scrollBefore,
        };

        if (!bodyNotScrolled) {
          overallOk = false;
        }
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `文件树滚动 body 空白检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 6: 本地 Markdown 标题：File Name.md 且正文第一行 # Body Heading 时，
    //          File Tree 显示 "File Name.md"，正文仍显示 "Body Heading"
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "markdown-title-from-filename-not-h1";
      try {
        const fileName = "MyNotes.md";
        const fileRelPath = fileName;
        // 确认文件已被写入
        assert(fs.existsSync(path.join(root, fileName)), "File Name.md 应存在");

        // 导航到该文件
        await page.goto(documentUrl(root, fileRelPath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        // 等待文件树就绪
        await page.locator("#sidebar-file-tree-root").waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        await page.waitForTimeout(500);

        // 从文件树获知 MyNotes.md 的标题
        const treeTitle = await page.evaluate(() => {
          const rows = document.querySelectorAll('#sidebar-file-tree-root .tree-row[data-row-id^="local:markdown:"]');
          for (const row of rows) {
            const text = row.querySelector(".tree-link-title")?.textContent?.trim() || row.textContent?.trim() || "";
            if (text.includes("MyNotes")) return text;
          }
          return null;
        });

        // 获取正文第一行 / 标题
        const bodyHeading = await page.evaluate(() => {
          const editor = document.querySelector('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror');
          if (!(editor instanceof HTMLElement)) return null;
          // 尝试获取第一个 h1
          const h1 = editor.querySelector("h1");
          if (h1 instanceof HTMLElement) return h1.textContent?.trim() || "";
          // fallback: textContent 第一行
          return (editor.textContent || "").split("\n").map((l) => l.trim()).filter(Boolean)[0] || "";
        });

        // 预期：文件树显示 MyNotes.md（文件名），正文显示 Body Heading（H1 内容）
        const treeShowsFileName = treeTitle && treeTitle.includes("MyNotes.md");
        const bodyShowsHeading = bodyHeading && bodyHeading.includes("Body Heading");
        // 托管工作区（我的空间）默认显示页头标题。
        const titleHeaderInitiallyVisible = await page.evaluate(() => {
          const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
          return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
        });

        await page.locator('[data-testid="wolai-page-settings-trigger"]').click({ timeout: UI_TIMEOUT_MS });
        const hideTitleCheckbox = page.locator('[data-page-option-checkbox="hideTitleHeader"]').first();
        await hideTitleCheckbox.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
        const hideTitleCheckedBefore = await hideTitleCheckbox.isChecked();
        assert.equal(hideTitleCheckedBefore, false, "托管工作区（我的空间）默认不隐藏文件标题");
        await hideTitleCheckbox.setChecked(true, { timeout: UI_TIMEOUT_MS });
        const optionsPath = path.join(root, ".mnote", "page-options.json");
        const optionsHidden = await waitForFileContent(
          optionsPath,
          (content) => {
            try {
              const payload = JSON.parse(content);
              return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === true;
            } catch {
              return false;
            }
          },
          UI_TIMEOUT_MS,
        );
        assert(optionsHidden.ok, `隐藏标题应持久化 hideTitleHeader=true，实际: ${optionsHidden.content}`);
        await page.waitForTimeout(500);
        const titleHeaderHiddenAfterCheck = await page.evaluate(() => {
          const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
          return header instanceof HTMLElement ? header.hidden || header.getAttribute("data-page-title-hidden") === "true" : false;
        });
        assert(titleHeaderHiddenAfterCheck, "勾选隐藏标题后标题应隐藏");

        await hideTitleCheckbox.setChecked(false, { timeout: UI_TIMEOUT_MS });
        const optionsShown = await waitForFileContent(
          optionsPath,
          (content) => {
            try {
              const payload = JSON.parse(content);
              return payload?.pages?.["local-md:MyNotes.md"]?.hideTitleHeader === false;
            } catch {
              return false;
            }
          },
          UI_TIMEOUT_MS,
        );
        assert(optionsShown.ok, `显示标题应持久化 hideTitleHeader=false，实际: ${optionsShown.content}`);
        await page.waitForTimeout(500);
        const titleHeaderVisibleAfterToggle = await page.evaluate(() => {
          const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
          return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
        });
        await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        const titleHeaderVisibleAfterReload = await page.evaluate(() => {
          const header = document.querySelector('.document-pane[data-pane-role="primary"] .document-shell-header');
          return header instanceof HTMLElement ? !header.hidden && header.getAttribute("data-page-title-hidden") === "false" : false;
        });

        checks[checkId] = {
          ok: !!(treeShowsFileName && bodyShowsHeading && titleHeaderInitiallyVisible && titleHeaderVisibleAfterToggle && titleHeaderVisibleAfterReload),
          message: `treeTitle="${treeTitle}" bodyHeading="${bodyHeading}"`,
          treeTitle,
          bodyHeading,
          treeShowsFileName,
          bodyShowsHeading,
          titleHeaderInitiallyVisible,
          hideTitleCheckedBefore,
          titleHeaderHiddenAfterCheck,
          titleHeaderVisibleAfterToggle,
          titleHeaderVisibleAfterReload,
        };
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `标题来源检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ──────────────────────────────────────────────────────────────────────────
    // Check 7: 主编辑区删除一个附件引用时，只删除正文链接，不级联删除真实文件；
    //          相邻附件仍保持可点击附件块，手柄删除刷新后不回来。
    // ──────────────────────────────────────────────────────────────────────────
    {
      const checkId = "editor-delete-one-attachment-preserves-adjacent";
      try {
        const keyboardPath = "DeleteKeyboard.md";
        const keyboardFile = path.join(root, keyboardPath);
        await page.goto(documentUrl(root, keyboardPath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        await waitForPrimaryAttachment(page, "first.md");
        await waitForPrimaryAttachment(page, "second.md");
        await deleteAttachmentByKeyboard(page, "second.md");

        const keyboardPersisted = await waitForFileContent(
          keyboardFile,
          (content) => content.includes("[first.md](DeleteKeyboard/first.md)") && !content.includes("second.md"),
          UI_TIMEOUT_MS,
        );
        assert(
          keyboardPersisted.ok,
          `Backspace 删除第二个附件后，Markdown 应保留 first 链接且移除 second。实际内容:\n${keyboardPersisted.content}`,
        );
        assert(fs.existsSync(path.join(root, "DeleteKeyboard", "first.md")), "Backspace 删除引用不应删除 first.md 真实文件");
        assert(fs.existsSync(path.join(root, "DeleteKeyboard", "second.md")), "Backspace 删除引用不应删除 second.md 真实文件");

        await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
        await waitForPrimaryAttachment(page, "first.md");
        const keyboardSecondVisible = await page
          .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" })
          .first()
          .isVisible()
          .catch(() => false);
        assert(!keyboardSecondVisible, "Backspace 删除后刷新，second.md 引用不应回来");

        const handlePath = "DeleteHandle.md";
        const handleFile = path.join(root, handlePath);
        await page.goto(documentUrl(root, handlePath), {
          waitUntil: "domcontentloaded",
          timeout: UI_TIMEOUT_MS,
        });
        await page.locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror').first().waitFor({
          state: "visible",
          timeout: UI_TIMEOUT_MS,
        });
        await waitForPrimaryAttachment(page, "first.md");
        await waitForPrimaryAttachment(page, "second.md");
        await deleteAttachmentByBlockHandle(page, "second.md");

        const handlePersisted = await waitForFileContent(
          handleFile,
          (content) => content.includes("[first.md](DeleteHandle/first.md)") && !content.includes("second.md"),
          UI_TIMEOUT_MS,
        );
        assert(
          handlePersisted.ok,
          `手柄删除第二个附件后，Markdown 应保留 first 链接且移除 second。实际内容:\n${handlePersisted.content}`,
        );
        assert(fs.existsSync(path.join(root, "DeleteHandle", "first.md")), "手柄删除引用不应删除 first.md 真实文件");
        assert(fs.existsSync(path.join(root, "DeleteHandle", "second.md")), "手柄删除引用不应删除 second.md 真实文件");

        await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
        await waitForPrimaryAttachment(page, "first.md");
        const handleSecondVisible = await page
          .locator('.document-pane[data-pane-role="primary"] .editor-surface .ProseMirror a', { hasText: "second.md" })
          .first()
          .isVisible()
          .catch(() => false);
        assert(!handleSecondVisible, "手柄删除后刷新，second.md 引用不应回来");

        checks[checkId] = {
          ok: true,
          message: "Backspace 与手柄删除单个附件引用后，相邻附件仍是可点击附件块，真实文件保留，刷新后被删引用不回来",
          keyboardContent: keyboardPersisted.content,
          handleContent: handlePersisted.content,
        };
      } catch (err) {
        overallOk = false;
        checks[checkId] = {
          ok: false,
          message: `删除单个附件引用检查失败: ${err.message}`,
          error: err.stack,
        };
      }
    }

    // ── 截图 ──
    {
      const shotTimestamp = Date.now();
      const shotDir = path.join(root, `screenshots-${shotTimestamp}`);
      fs.mkdirSync(shotDir, { recursive: true });
      await page.screenshot({ path: path.join(shotDir, "final-state.png"), fullPage: false });
      screenshots.push(path.join(shotDir, "final-state.png"));
    }

    // ── 结果 ──
    const result = {
      ok: overallOk,
      root,
      checks,
      screenshots,
      popups: popups.length,
    };
    console.log(JSON.stringify(result, null, 2));
  } finally {
    await context.close().catch(() => undefined);
    await browser.close().catch(() => undefined);
    // 清理临时目录，保留 result JSON 被 console.log 输出即可
    // 如果用户需要查看留下的文件，可设置 KEEP_TEMP=1
    if (!process.env.KEEP_TEMP) {
      try { fs.rmSync(root, { recursive: true, force: true }); } catch (_) { /* ignore cleanup errors */ }
    }
  }
}

main().catch((error) => {
  console.error(error && error.stack ? error.stack : error);
  process.exit(1);
});
