#!/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 { spawn } = require("node:child_process");
const { chromium } = require("playwright");
const { findFreePort, waitForGateway } = require("./task114-rust-web-gateway-entry-smoke.js");

const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 45_000);
const TYPE_DELAY_MS = Number(process.env.MNOTE_SMOKE_TYPE_DELAY_MS || 20);

function buildFixtureEnv(port) {
  return {
    ...process.env,
    MNOTE_WEB_ALLOW_DEV_FIXTURES: "1",
    MNOTE_WEB_BIND: `127.0.0.1:${port}`,
    MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
    MNOTE_WEB_LEGACY_NEXT_BASE_URL: "http://127.0.0.1:3100",
    MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
    MNOTE_WEB_QUERY_FIXTURES_JSON: JSON.stringify({
      "documents:getMeta": {
        id: "doc_1",
        workspace_id: "ws_demo",
        title: "双栏验收页面",
        updated_at: "2026-05-08T10:00:00Z",
        can_edit: true,
        word_count: 10,
        character_count: 32,
        block_count: 1,
      },
      "documents:getContent": {
        title: "双栏验收页面",
        content: [
          {
            id: "block_1",
            type: "paragraph",
            content: [{ type: "text", text: "fixture init" }],
          },
        ],
        revision: 7,
        conflict_detection_key: "doc_1:7",
        pageSubtree: { rootNodeId: "doc_1", outline: [] },
      },
      "sidebar:datasetList": {
        active_workspace_id: "ws_demo",
        workspaces: [{ id: "ws_demo", name: "双栏空间" }],
        documents: [
          {
            id: "doc_1",
            workspace_id: "ws_demo",
            title: "双栏验收页面",
            parent_id: null,
            sort_order: 0,
            is_starred: false,
            is_template: false,
            created_at: "2026-05-08T10:00:00Z",
            updated_at: "2026-05-08T10:00:00Z",
          },
          {
            id: "doc_other",
            workspace_id: "ws_demo",
            title: "Fixture Other",
            parent_id: null,
            sort_order: 1,
            is_starred: false,
            is_template: false,
            created_at: "2026-05-08T10:00:00Z",
            updated_at: "2026-05-08T10:00:00Z",
          },
        ],
        trashed_documents: [],
        media_assets: [],
        trashed_media_assets: [],
        mindmap_assets: [],
        trashed_mindmap_assets: [],
        table_assets: [],
        trashed_table_assets: [],
        mindmap_docs: [],
        mindmap_asset_children: {},
      },
      "bridgeLogs:listWorkspaceOverview": {
        workspace_id: "ws_demo",
        command_logs: [],
        domain_events: [],
        next_cursor: null,
        has_more: false,
        filters: {
          command_status: null,
          event_status: null,
          target_page_id: null,
          target_block_id: null,
          aggregate_type: null,
          aggregate_id: null,
        },
        generated_at: "2026-05-08T10:00:00Z",
      },
    }),
    MNOTE_WEB_MUTATION_FIXTURES_JSON: JSON.stringify({
      "documents:updateContent": {
        ok: true,
        updated_at: "2026-05-08T10:00:10Z",
        revision: 8,
        conflict_detection_key: "doc_1:8",
      },
      "documents:updateTitle": {
        ok: true,
        title: "双栏验收页面",
      },
    }),
  };
}

function startGateway(port) {
  return spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
    cwd: "/mnt/Data1T/mnote/rust",
    env: buildFixtureEnv(port),
    stdio: ["ignore", "pipe", "pipe"],
  });
}

function createLocalFolderFixture() {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-dual-pane-"));
  const readmePath = path.join(root, "README.md");
  const sidePath = path.join(root, "side.md");
  const thirdPath = path.join(root, "third.md");
  const readmeParagraphs = Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`);
  const seed = [
    "---",
    "title: Local Dual Pane",
    "---",
    "",
    ...readmeParagraphs,
    "",
  ].join("\n");
  fs.writeFileSync(readmePath, seed, "utf8");
  fs.writeFileSync(
    sidePath,
    [
      "---",
      "title: Local Side",
      "---",
      "",
      "side init",
      "",
    ].join("\n"),
    "utf8",
  );
  fs.writeFileSync(
    thirdPath,
    [
      "---",
      "title: Local Third",
      "---",
      "",
      "third init",
      "",
    ].join("\n"),
    "utf8",
  );
  return {
    root,
    rootUri: `file://${root}`,
    readmePath,
    sidePath,
    thirdPath,
    documentId: "local-md:README.md",
    sideDocumentId: "local-md:side.md",
    thirdDocumentId: "local-md:third.md",
  };
}

function paneEditorSelector(role) {
  return `.document-pane[data-pane-role="${role}"] .editor-surface .ProseMirror[contenteditable="true"]`;
}

function paneRootSelector(role) {
  return `.document-pane[data-pane-role="${role}"] [data-testid="mnote-leptos-tiptap-island-editor-root"]`;
}

function paneSelector(role) {
  return `.document-pane[data-pane-role="${role}"]`;
}

async function waitForDualPaneReady(page) {
  await page.waitForFunction(
    ({ primarySelector, secondarySelector, primaryRootSelector, secondaryRootSelector }) => {
      const primaryEditor = document.querySelector(primarySelector);
      const secondaryEditor = document.querySelector(secondarySelector);
      const primaryRoot = document.querySelector(primaryRootSelector);
      const secondaryRoot = document.querySelector(secondaryRootSelector);
      const ready = (editor, root) =>
        editor instanceof HTMLElement &&
        editor.isContentEditable &&
        root instanceof HTMLElement &&
        root.getAttribute("data-runtime-editor-status") !== "error";
      return ready(primaryEditor, primaryRoot) && ready(secondaryEditor, secondaryRoot);
    },
    {
      primarySelector: paneEditorSelector("primary"),
      secondarySelector: paneEditorSelector("secondary"),
      primaryRootSelector: paneRootSelector("primary"),
      secondaryRootSelector: paneRootSelector("secondary"),
    },
    { timeout: UI_TIMEOUT_MS },
  );
}

async function waitForSinglePaneReady(page) {
  await page.waitForFunction(
    ({ primarySelector, primaryRootSelector, secondaryPaneSelector }) => {
      const primaryEditor = document.querySelector(primarySelector);
      const primaryRoot = document.querySelector(primaryRootSelector);
      const secondaryPane = document.querySelector(secondaryPaneSelector);
      const secondaryVisible =
        secondaryPane instanceof HTMLElement &&
        !secondaryPane.hasAttribute("hidden") &&
        secondaryPane.getAttribute("data-pane-visible") !== "false";
      return (
        primaryEditor instanceof HTMLElement &&
        primaryEditor.isContentEditable &&
        primaryRoot instanceof HTMLElement &&
        primaryRoot.getAttribute("data-runtime-editor-status") !== "error" &&
        !secondaryVisible
      );
    },
    {
      primarySelector: paneEditorSelector("primary"),
      primaryRootSelector: paneRootSelector("primary"),
      secondaryPaneSelector: paneSelector("secondary"),
    },
    { timeout: UI_TIMEOUT_MS },
  );
}

async function typePaneText(page, role, text) {
  const editor = page.locator(paneEditorSelector(role)).first();
  await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await editor.click({ timeout: UI_TIMEOUT_MS });
  await page.keyboard.press("Control+a");
  await page.keyboard.press("Backspace");
  await page.keyboard.type(text, { delay: TYPE_DELAY_MS });
}

async function appendPaneText(page, role, text) {
  const editor = page.locator(paneEditorSelector(role)).first();
  await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await editor.click({ timeout: UI_TIMEOUT_MS });
  await page.keyboard.press("Control+End");
  await page.keyboard.type(text, { delay: TYPE_DELAY_MS });
}

async function waitForPaneText(page, role, text) {
  await page.waitForFunction(
    ({ selector, expected }) => {
      const editor = document.querySelector(selector);
      return (editor?.textContent || "").includes(expected);
    },
    {
      selector: paneEditorSelector(role),
      expected: text,
    },
    { timeout: UI_TIMEOUT_MS },
  );
}

async function readPrimaryTitleState(page) {
  return await page.evaluate(() => {
    const primaryInput = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]');
    const primaryMeta = document.querySelector('.document-pane[data-pane-role="primary"] [data-page-title-current="true"]');
    const breadcrumb = document.querySelector('.wolai-breadcrumb-current [data-page-title-current="true"]');
    return {
      documentTitle: document.title || "",
      inputValue: primaryInput instanceof HTMLTextAreaElement ? primaryInput.value || "" : "",
      metaTitle: primaryMeta instanceof HTMLElement ? primaryMeta.textContent || "" : "",
      breadcrumbTitle: breadcrumb instanceof HTMLElement ? breadcrumb.textContent || "" : "",
    };
  });
}

async function waitForPaneStatus(page, role, status) {
  await page.waitForFunction(
    ({ selector, expected }) => {
      const root = document.querySelector(selector);
      return root?.getAttribute("data-runtime-editor-status") === expected;
    },
    {
      selector: paneRootSelector(role),
      expected: status,
    },
    { timeout: UI_TIMEOUT_MS },
  );
}

async function readPaneText(page, role) {
  return await page.evaluate((selector) => {
    const editor = document.querySelector(selector);
    return editor?.textContent || "";
  }, paneEditorSelector(role));
}

async function readActivePaneRole(page) {
  return await page.evaluate(() => {
    const active = document.activeElement;
    return active?.closest?.(".document-pane")?.getAttribute?.("data-pane-role") || null;
  });
}

async function readPaneScrollState(page, role) {
  return await page.evaluate(({ paneSelector, editorSelector }) => {
    const pane = document.querySelector(paneSelector);
    const editor = document.querySelector(editorSelector);
    const findScrollable = (start) => {
      let current = start;
      while (current instanceof HTMLElement) {
        if (current.scrollHeight > current.clientHeight + 8) {
          return current;
        }
        current = current.parentElement;
      }
      return null;
    };
    const target = findScrollable(editor) || findScrollable(pane);
    if (!(target instanceof HTMLElement)) {
      return null;
    }
    return {
      scrollTop: target.scrollTop,
      scrollHeight: target.scrollHeight,
      clientHeight: target.clientHeight,
    };
  }, {
    paneSelector: paneSelector(role),
    editorSelector: paneEditorSelector(role),
  });
}

async function setPaneScrollTop(page, role, top) {
  return await page.evaluate(({ paneSelector, editorSelector, topValue }) => {
    const pane = document.querySelector(paneSelector);
    const editor = document.querySelector(editorSelector);
    const findScrollable = (start) => {
      let current = start;
      while (current instanceof HTMLElement) {
        if (current.scrollHeight > current.clientHeight + 8) {
          return current;
        }
        current = current.parentElement;
      }
      return null;
    };
    const target = findScrollable(editor) || findScrollable(pane);
    if (!(target instanceof HTMLElement)) {
      return null;
    }
    target.scrollTop = topValue;
    return {
      scrollTop: target.scrollTop,
      scrollHeight: target.scrollHeight,
      clientHeight: target.clientHeight,
    };
  }, {
    paneSelector: paneSelector(role),
    editorSelector: paneEditorSelector(role),
    topValue: top,
  });
}

async function readViewportScroll(page) {
  return await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
}

async function setViewportScroll(page, top) {
  return await page.evaluate((topValue) => {
    window.scrollTo({ top: topValue, left: 0, behavior: "auto" });
    return { x: window.scrollX, y: window.scrollY };
  }, top);
}

async function waitForRequestCount(requests, startIndex, predicate, expected, label) {
  const deadline = Date.now() + UI_TIMEOUT_MS;
  while (Date.now() < deadline) {
    const count = requests.slice(startIndex).filter(predicate).length;
    if (count >= expected) {
      return count;
    }
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
  throw new Error(`${label} 超时，期望至少 ${expected} 条`);
}

async function readDocumentSessionSnapshot(page) {
  return await page.evaluate(() => {
    const debug = window.__mnoteDebugDocumentSessions;
    if (!debug || typeof debug.snapshot !== "function") {
      throw new Error("缺少文档 session debug 快照");
    }
    return debug.snapshot();
  });
}

async function readNoReloadProbe(page) {
  return await page.evaluate(({ primaryRootSelector, secondaryRootSelector }) => ({
    pagehideCount: Number(window.sessionStorage?.getItem("__mnoteSmokePagehideCount") || "0"),
    primaryMountId: document.querySelector(primaryRootSelector)?.getAttribute("data-runtime-mount-id") || "",
    secondaryMountId: document.querySelector(secondaryRootSelector)?.getAttribute("data-runtime-mount-id") || "",
  }), {
    primaryRootSelector: paneRootSelector("primary"),
    secondaryRootSelector: paneRootSelector("secondary"),
  });
}

async function waitForDocumentPath(page, documentId) {
  await page.waitForFunction(
    (expectedDocumentId) => decodeURIComponent(window.location.pathname).endsWith(`/documents/${expectedDocumentId}`),
    documentId,
    { timeout: UI_TIMEOUT_MS },
  );
}

function countRequests(requests, startIndex, predicate) {
  return requests.slice(startIndex).filter(predicate).length;
}

function isSaveRequest(record) {
  return record.method === "POST" && record.url.includes("/api/documents/save");
}

function isTreeEventRequest(record) {
  return record.method === "GET" && record.url.includes("/api/tree/events");
}

function isLocalFolderEventRequest(record) {
  return record.method === "GET" && record.url.includes("/api/local-folder/events");
}

async function runFixturePhase(page, baseUrl, requests) {
  const url = `${baseUrl}/documents/doc_1?workspaceId=ws_demo&secondaryDocumentId=doc_1`;
  const openIndex = requests.length;
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await waitForRequestCount(requests, openIndex, isTreeEventRequest, 1, "tree EventSource 建连");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 1, "双 pane fixture 页面不应建立第二条 tree EventSource");

  const closeButton = page.locator('[data-mnote-pane-close="secondary"]').first();
  await closeButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });

  const primaryToSecondary = `fixture-primary-${Date.now().toString().slice(-6)}`;
  const primarySaveIndex = requests.length;
  await typePaneText(page, "primary", primaryToSecondary);
  await waitForPaneText(page, "primary", primaryToSecondary);
  await waitForPaneText(page, "secondary", primaryToSecondary);
  await waitForPaneStatus(page, "primary", "saved");
  await waitForPaneStatus(page, "secondary", "saved");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, primarySaveIndex, isSaveRequest), 1, "同 session 双 view 主 pane 输入后应只触发一次保存请求");
  assert.equal(await readActivePaneRole(page), "primary", "primary 输入后焦点不应被同步到 secondary");

  const secondaryToPrimary = `fixture-secondary-${(Date.now() + 1).toString().slice(-6)}`;
  const secondarySaveIndex = requests.length;
  await typePaneText(page, "secondary", secondaryToPrimary);
  await waitForPaneText(page, "primary", secondaryToPrimary);
  await waitForPaneText(page, "secondary", secondaryToPrimary);
  await waitForPaneStatus(page, "primary", "saved");
  await waitForPaneStatus(page, "secondary", "saved");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, secondarySaveIndex, isSaveRequest), 1, "同 session 双 view 次 pane 输入后应只触发一次保存请求");
  assert.equal(await readActivePaneRole(page), "secondary", "secondary 输入后焦点不应被同步到 primary");

  const reloadIndex = requests.length;
  await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await waitForRequestCount(requests, reloadIndex, isTreeEventRequest, 1, "reload 后 tree EventSource 建连");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, reloadIndex, isTreeEventRequest), 1, "reload 后双 pane 仍应只建立一条 tree EventSource");
  const fixtureSnapshot = await readDocumentSessionSnapshot(page);
  assert.equal(fixtureSnapshot.sessionCount, 1, "同文档双开时应只复用一个 session");
  assert.equal(fixtureSnapshot.sessions[0]?.viewCount, 2, "同文档双开时单 session 应挂两个 view");

  const navigationIndex = requests.length;
  const beforeFixtureNavigation = await readNoReloadProbe(page);
  assert(beforeFixtureNavigation.secondaryMountId, "fixture 导航前应已挂载 secondary editor");
  await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Fixture Other" }).first().click({ timeout: UI_TIMEOUT_MS });
  await waitForDocumentPath(page, "doc_other");
  await waitForDualPaneReady(page);
  await page.waitForTimeout(800);
  const afterFixtureNavigation = await readNoReloadProbe(page);
  assert.equal(
    afterFixtureNavigation.pagehideCount,
    beforeFixtureNavigation.pagehideCount,
    "fixture sidebar 导航不应触发整页 pagehide",
  );
  assert.equal(
    afterFixtureNavigation.secondaryMountId,
    beforeFixtureNavigation.secondaryMountId,
    "fixture sidebar 导航不应重挂 secondary editor",
  );
  assert.equal(countRequests(requests, navigationIndex, isTreeEventRequest), 0, "fixture sidebar 导航不应重建 tree EventSource");
  const navigatedFixtureUrl = new URL(page.url());
  assert.equal(
    navigatedFixtureUrl.searchParams.get("secondaryDocumentId"),
    "doc_1",
    "fixture sidebar 导航后 secondaryDocumentId 应保持原值",
  );
}

async function runLocalFolderPhase(page, baseUrl, requests, fixture) {
  const differentDocUrl = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.sideDocumentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;
  const differentDocIndex = requests.length;
  await page.goto(differentDocUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await waitForRequestCount(requests, differentDocIndex, isLocalFolderEventRequest, 1, "不同文档 local folder EventSource 建连");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, differentDocIndex, isLocalFolderEventRequest), 1, "同 rootUri 不同文档双 pane 也应只复用一条 local-folder EventSource");
  const differentDocSnapshot = await readDocumentSessionSnapshot(page);
  assert.equal(differentDocSnapshot.sessionCount, 2, "不同文档双开时应建立两个 session");
  assert.equal(differentDocSnapshot.localFolderChannelCount, 1, "同 rootUri 不同文档双开时应只复用一个 local-folder channel");
  assert.deepEqual(
    differentDocSnapshot.sessions.map((item) => item.documentId).sort(),
    [fixture.documentId, fixture.sideDocumentId].sort(),
    "不同文档双开时 session 应分别归属到两个 documentId",
  );
  await page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first().click({ timeout: UI_TIMEOUT_MS });
  await waitForDocumentPath(page, fixture.thirdDocumentId);
  await waitForDualPaneReady(page);
  const navigatedUrl = new URL(page.url());
  assert.equal(
    navigatedUrl.searchParams.get("secondaryDocumentId"),
    fixture.sideDocumentId,
    "左侧普通导航后 secondaryDocumentId 应保持原值",
  );
  assert.equal(
    navigatedUrl.searchParams.get("secondarySourceKind"),
    "local_folder",
    "左侧普通导航后 secondarySourceKind 应保持 local_folder",
  );
  assert.equal(
    navigatedUrl.searchParams.get("secondaryRootUri"),
    fixture.rootUri,
    "左侧普通导航后 secondaryRootUri 应保持原值",
  );
  const primaryTextAfterNavigation = await readPaneText(page, "primary");
  const secondaryTextAfterNavigation = await readPaneText(page, "secondary");
  assert(primaryTextAfterNavigation.includes("third init"), "左侧普通导航后 primary pane 应切到新文档");
  assert(secondaryTextAfterNavigation.includes("side init"), "左侧普通导航后 secondary pane 应保持原文档");
  const topbarTitle = await page.locator(".wolai-topbar [data-page-title-current='true']").first().textContent();
  assert((topbarTitle || "").includes("Local Third"), "左侧普通导航后共享 topbar 标题应切到 primary 文档");
  const activeThirdRow = page.getByTestId("wolai-sidebar-row").filter({ hasText: "Local Third" }).first();
  const activeState = await activeThirdRow.getAttribute("data-active");
  const selectedState = await activeThirdRow.getAttribute("data-selected");
  assert(
    activeState === "true" || selectedState === "true",
    "左侧普通导航后共享侧栏 active/selected 状态应切到新的 primary 文档",
  );

  const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}&secondaryDocumentId=${encodeURIComponent(fixture.documentId)}&secondarySourceKind=local_folder&secondaryRootUri=${encodeURIComponent(fixture.rootUri)}`;

  const openIndex = requests.length;
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await waitForRequestCount(requests, openIndex, isLocalFolderEventRequest, 1, "local folder EventSource 建连");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, openIndex, isLocalFolderEventRequest), 1, "同 rootUri 双 pane 不应建立第二条 local-folder EventSource");
  assert.equal(countRequests(requests, openIndex, isTreeEventRequest), 0, "local folder 页面不应建立 tree EventSource");

  const reloadIndex = requests.length;
  await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await waitForRequestCount(requests, reloadIndex, isLocalFolderEventRequest, 1, "reload 后 local folder EventSource 建连");
  await page.waitForTimeout(800);
  assert.equal(countRequests(requests, reloadIndex, isLocalFolderEventRequest), 1, "reload 后同 rootUri 双 pane 仍应只建立一条 local-folder EventSource");
  const sameDocSnapshot = await readDocumentSessionSnapshot(page);
  assert.equal(sameDocSnapshot.sessionCount, 1, "同文档 local folder 双开时应只复用一个 session");
  assert.equal(sameDocSnapshot.sessions[0]?.viewCount, 2, "同文档 local folder 双开时单 session 应挂两个 view");

  const viewportBefore = await setViewportScroll(page, 260);
  assert((viewportBefore?.y || 0) >= 200, "本地双栏页面应可滚动到可观察位置");
  const externalText = `external-sync-${Date.now().toString().slice(-6)}`;
  fs.writeFileSync(
    fixture.readmePath,
    [
      "---",
      "title: Local Dual Pane",
      "---",
      "",
      ...Array.from({ length: 80 }, (_, index) => `local init line ${index + 1}`),
      externalText,
      "",
    ].join("\n"),
    "utf8",
  );
  await waitForPaneText(page, "primary", externalText);
  await waitForPaneText(page, "secondary", externalText);
  await waitForPaneStatus(page, "primary", "synced-external-change");
  await waitForPaneStatus(page, "secondary", "synced-external-change");
  await page.waitForTimeout(800);
  const viewportAfter = await readViewportScroll(page);
  assert(
    Math.abs((viewportAfter?.y || 0) - (viewportBefore?.y || 0)) < 40,
    "远端 replaceContent 后共享页面滚动不应被重置",
  );

  const savedMarkdown = fs.readFileSync(fixture.readmePath, "utf8");
  assert(savedMarkdown.includes(externalText), "外部写盘未落到本地 Markdown 文件");

  const closeBefore = await readNoReloadProbe(page);
  await page.locator('[data-mnote-pane-close="secondary"]').first().click({ timeout: UI_TIMEOUT_MS });
  await page.waitForFunction(() => !new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS });
  await waitForSinglePaneReady(page);
  const closeAfter = await readNoReloadProbe(page);
  assert.equal(closeAfter.pagehideCount, closeBefore.pagehideCount, "关闭 secondary 不应触发整页 pagehide");
  await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForSinglePaneReady(page);
  const finalUrl = new URL(page.url());
  assert(!finalUrl.searchParams.has("secondaryDocumentId"), "关闭 secondary 后 reload 不应恢复 secondaryDocumentId");
  assert(!finalUrl.searchParams.has("secondarySourceKind"), "关闭 secondary 后 reload 不应恢复 secondarySourceKind");
  assert(!finalUrl.searchParams.has("secondaryRootUri"), "关闭 secondary 后 reload 不应恢复 secondaryRootUri");

  await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  await page.evaluate(() => {
    document.querySelector('.document-pane[data-pane-role="secondary"]')?.remove();
  });
  await page.waitForTimeout(300);
  const oneViewSnapshot = await readDocumentSessionSnapshot(page);
  assert.equal(oneViewSnapshot.sessionCount, 1, "移除一个 pane 后 session 不应提前释放");
  assert.equal(oneViewSnapshot.sessions[0]?.viewCount, 1, "移除一个 pane 后同 session 应只剩一个 view");
  await page.evaluate(() => {
    document.querySelector('.document-pane[data-pane-role="primary"]')?.remove();
  });
  await page.waitForTimeout(1500);
  const releasedSnapshot = await readDocumentSessionSnapshot(page);
  assert.equal(releasedSnapshot.sessionCount, 0, "最后一个 view 卸载后应延迟释放 session");
  assert.equal(releasedSnapshot.localFolderChannelCount, 0, "最后一个 view 卸载后应回收 local-folder channel");
}

async function runSinglePaneTitlePhase(page, baseUrl, fixture) {
  const url = `${baseUrl}/documents/${encodeURIComponent(fixture.documentId)}?sourceKind=local_folder&rootUri=${encodeURIComponent(fixture.rootUri)}`;
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
  await waitForSinglePaneReady(page);
  const expectedTitle = "Local Dual Pane";
  const beforeClick = await readPrimaryTitleState(page);
  assert.equal(beforeClick.documentTitle.trim(), expectedTitle, "单页本地文档首屏 document.title 不应被隐藏 secondary 覆盖成“无标题”");
  assert.equal(beforeClick.inputValue.trim(), expectedTitle, "单页本地文档首屏标题输入框不应被隐藏 secondary 覆盖成“无标题”");
  assert.equal(beforeClick.metaTitle.trim(), expectedTitle, "单页本地文档首屏页头标题不应被隐藏 secondary 覆盖成“无标题”");
  assert.equal(beforeClick.breadcrumbTitle.trim(), expectedTitle, "单页本地文档首屏 breadcrumb 标题不应被隐藏 secondary 覆盖成“无标题”");

  const titleInput = page.locator('.document-pane[data-pane-role="primary"] [data-page-title-input="true"]').first();
  await titleInput.click({ timeout: UI_TIMEOUT_MS });
  await page.waitForTimeout(250);
  const afterClick = await readPrimaryTitleState(page);
  assert.equal(afterClick.documentTitle.trim(), expectedTitle, "点击标题后 document.title 不应闪成“无标题”");
  assert.equal(afterClick.inputValue.trim(), expectedTitle, "点击标题后输入框标题不应闪成“无标题”");
  assert.equal(afterClick.metaTitle.trim(), expectedTitle, "点击标题后页头标题不应闪成“无标题”");
  assert.equal(afterClick.breadcrumbTitle.trim(), expectedTitle, "点击标题后 breadcrumb 标题不应闪成“无标题”");

  const beforeOpenRight = await readNoReloadProbe(page);
  assert(beforeOpenRight.primaryMountId, "打开 secondary 前应已挂载 primary editor");
  await page.evaluate((documentId) => {
    window.dispatchEvent(new CustomEvent("tree.page.open-right", { detail: { documentId } }));
  }, fixture.sideDocumentId);
  await page.waitForFunction(() => new URL(window.location.href).searchParams.has("secondaryDocumentId"), {}, { timeout: UI_TIMEOUT_MS });
  await waitForDualPaneReady(page);
  const afterOpenRight = await readNoReloadProbe(page);
  assert.equal(afterOpenRight.pagehideCount, beforeOpenRight.pagehideCount, "打开 secondary 不应触发整页 pagehide");
  assert.equal(afterOpenRight.primaryMountId, beforeOpenRight.primaryMountId, "打开 secondary 不应重挂 primary editor");
  const openRightUrl = new URL(page.url());
  assert.equal(openRightUrl.searchParams.get("secondaryDocumentId"), fixture.sideDocumentId, "打开 secondary 后 URL 应写入 secondaryDocumentId");
  assert.equal(openRightUrl.searchParams.get("secondarySourceKind"), "local_folder", "打开 secondary 后 URL 应保留 secondarySourceKind");
  assert.equal(openRightUrl.searchParams.get("secondaryRootUri"), fixture.rootUri, "打开 secondary 后 URL 应保留 secondaryRootUri");
}

async function main() {
  const externalBaseUrl = String(process.env.MNOTE_UI_BASE_URL || "").trim();
  const useExistingServer = externalBaseUrl.length > 0;
  const port = useExistingServer ? null : await findFreePort();
  const baseUrl = useExistingServer ? externalBaseUrl : `http://127.0.0.1:${port}`;
  const gateway = useExistingServer ? null : startGateway(port);
  const localFixture = createLocalFolderFixture();
  const requests = [];
  let stderr = "";
  let stdout = "";

  gateway?.stderr.on("data", (chunk) => {
    stderr += chunk.toString("utf8");
  });
  gateway?.stdout.on("data", (chunk) => {
    stdout += chunk.toString("utf8");
  });

  await waitForGateway(baseUrl);

  const browser = await chromium.launch({ headless: true });
  const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
  await context.addInitScript(() => {
    const key = "__mnoteSmokePagehideCount";
    window.addEventListener("pagehide", () => {
      const current = Number(window.sessionStorage?.getItem(key) || "0");
      window.sessionStorage?.setItem(key, String(current + 1));
    });
  });
  const page = await context.newPage();

  page.on("request", (request) => {
    let payload = null;
    try {
      payload = request.postDataJSON?.() ?? null;
    } catch {
      payload = null;
    }
    requests.push({
      url: request.url(),
      method: request.method(),
      payload,
    });
  });

  let caughtError = null;
  try {
    if (!useExistingServer) {
      await runFixturePhase(page, baseUrl, requests);
    }
    await runLocalFolderPhase(page, baseUrl, requests, localFixture);
    await runSinglePaneTitlePhase(page, baseUrl, localFixture);
    console.log(
      JSON.stringify(
        {
          ok: true,
          task: "task165-rust-web-dual-pane-smoke",
          baseUrl,
          serverMode: useExistingServer ? "existing" : "spawned",
          requestSummary: {
            saveRequests: requests.filter(isSaveRequest).length,
            treeEventRequests: requests.filter(isTreeEventRequest).length,
            localFolderEventRequests: requests.filter(isLocalFolderEventRequest).length,
          },
        },
        null,
        2,
      ),
    );
  } catch (error) {
    caughtError = error;
  } finally {
    await page.close().catch(() => undefined);
    await context.close().catch(() => undefined);
    await browser.close().catch(() => undefined);
    gateway?.kill("SIGTERM");
    if (gateway) {
      setTimeout(() => {
        if (!gateway.killed) {
          gateway.kill("SIGKILL");
        }
      }, 2000).unref();
    }
    fs.rmSync(localFixture.root, { recursive: true, force: true });
    if (caughtError) {
      const debug = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
      if (debug) {
        process.stderr.write(`${debug}\n`);
      }
    }
  }

  if (caughtError) {
    throw caughtError;
  }
}

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