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

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

const TASK = "task433-filetree-trash-file-asset-dual-browser-no-refresh-smoke";
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const AUTH_BASE_URL = (process.env.MNOTE_AUTH_BASE_URL || BASE_URL).replace(/\/+$/, "");
const e2ePassword = () => process.env.MNOTE_E2E_PASSWORD || ["Mnote", "E2E", "123!"].join("");
// 说明：该 smoke 仍在验证 convex-source 的 file asset trash 兼容链路，不是 local-first 默认资源流。
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 35_000);
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");

async function writeResult(payload) {
  await fs.mkdir(OUTPUT_DIR, { recursive: true });
  await fs.writeFile(RESULT_PATH, `${JSON.stringify({ ...payload, resultPath: RESULT_PATH }, null, 2)}\n`, "utf8");
}

async function requestJson(request, requestPath, init = {}) {
  const response = await request.fetch(`${BASE_URL}${requestPath}`, {
    ...init,
    headers: {
      ...(init.data !== undefined ? { "content-type": "application/json" } : {}),
      ...(init.headers || {}),
    },
    timeout: 30_000,
  });
  const text = await response.text();
  let payload = null;
  try {
    payload = text ? JSON.parse(text) : null;
  } catch {
    payload = text;
  }
  if (!response.ok()) {
    throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
  }
  return payload;
}

async function requestAuthJson(request, requestPath, init = {}) {
  const response = await request.fetch(`${AUTH_BASE_URL}${requestPath}`, {
    ...init,
    headers: {
      ...(init.data !== undefined ? { "content-type": "application/json" } : {}),
      ...(init.headers || {}),
    },
    timeout: 20_000,
  });
  const text = await response.text();
  let payload = null;
  try {
    payload = text ? JSON.parse(text) : null;
  } catch {
    payload = text;
  }
  if (!response.ok()) {
    throw new Error(`${requestPath} 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
  }
  return payload;
}

function cssEscape(value) {
  return String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

function docRowSelector(documentId) {
  return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-row-id="doc:${cssEscape(documentId)}"]`;
}

function assetRowSelector(assetId) {
  return `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-asset-id="${cssEscape(assetId)}"]`;
}

function trashAssetRowSelector(assetId) {
  return `[data-testid="mnote-trash-workbench"] [data-trash-row="resource"][data-resource-kind="media"][data-resource-id="${cssEscape(assetId)}"]`;
}

async function authenticate(request, email, name) {
  await requestAuthJson(request, "/api/auth", {
    method: "POST",
    data: {
      action: "auth:signIn",
      args: {
        provider: "password",
        params: { email, password: e2ePassword(), flow: "signUp", name },
      },
    },
  });
}

async function createPage(request, workspaceId, title, parentId = null) {
  const payload = await requestJson(request, "/api/tree/commands", {
    method: "POST",
    data: { action: "create", workspaceId, parentId, title },
  });
  const result = payload.result || payload;
  const documentId = result.documentId || payload.documentId || result.id || "";
  const resolvedWorkspaceId = result.workspaceId || payload.workspaceId || payload.workspace_id || workspaceId || "";
  assert(documentId, `创建页面失败: ${JSON.stringify(payload)}`);
  assert(resolvedWorkspaceId, `创建页面缺少 workspaceId: ${JSON.stringify(payload)}`);
  return { documentId, workspaceId: resolvedWorkspaceId, payload };
}

async function treeCommand(request, workspaceId, action, documentId) {
  return await requestJson(request, "/api/tree/commands", {
    method: "POST",
    data: { action, workspaceId, documentId },
  });
}

async function uploadFileAsset(request, workspaceId, documentId, fileName, text) {
  const response = await request.fetch(`${BASE_URL}/api/media/upload`, {
    method: "POST",
    multipart: {
      workspaceId,
      documentId,
      file: {
        name: fileName,
        mimeType: "text/plain",
        buffer: Buffer.from(text, "utf8"),
      },
    },
    timeout: 30_000,
  });
  const payload = await response.json().catch(async () => await response.text());
  if (!response.ok()) {
    throw new Error(`/api/media/upload 请求失败: ${response.status()} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
  }
  const assetId = payload?.asset?.id || payload?.assetId || "";
  assert(assetId, `上传附件缺少 asset id: ${JSON.stringify(payload)}`);
  return { assetId, payload };
}

async function archiveAsset(request, assetId) {
  return await requestJson(request, "/api/media/batch", {
    method: "POST",
    data: { action: "delete", assetIds: [assetId] },
  });
}

async function restoreAsset(request, assetId) {
  return await requestJson(request, "/api/media/batch", {
    method: "POST",
    data: { action: "restore", assetIds: [assetId] },
  });
}

async function purgeAsset(request, assetId) {
  return await requestJson(request, "/api/media/purge", {
    method: "POST",
    data: { assetId },
  });
}

async function emptyResourceTrash(request, workspaceId) {
  return await requestJson(request, "/api/media/empty-trash", {
    method: "POST",
    data: { workspaceId },
  });
}

async function openDocumentFileTree(page, workspaceId, documentId) {
  await page.goto(`${BASE_URL}/documents/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`, {
    waitUntil: "commit",
    timeout: UI_TIMEOUT_MS,
  });
  await page.waitForFunction(
    () => {
      const fileRoot = document.getElementById("sidebar-file-tree-root");
      const fileTab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
      return Boolean(fileRoot || fileTab);
    },
    undefined,
    { timeout: UI_TIMEOUT_MS },
  );
  await page.evaluate(() => {
    const visible = (node) =>
      node instanceof HTMLElement &&
      !node.hidden &&
      getComputedStyle(node).display !== "none" &&
      getComputedStyle(node).visibility !== "hidden" &&
      node.getClientRects().length > 0;
    const fileRoot = document.getElementById("sidebar-file-tree-root");
    if (visible(fileRoot)) return;
    const tab = document.querySelector('[data-mnote-sidebar-tree-tab="filetree"], button[aria-label="文件"]');
    if (tab instanceof HTMLElement) tab.click();
  });
  await page.waitForFunction(
    () => {
      const fileRoot = document.getElementById("sidebar-file-tree-root");
      return fileRoot instanceof HTMLElement && fileRoot.getClientRects().length > 0;
    },
    undefined,
    { timeout: UI_TIMEOUT_MS },
  );
}

async function openTrash(page, workspaceId) {
  await page.goto(`${BASE_URL}/trash?workspaceId=${encodeURIComponent(workspaceId)}`, {
    waitUntil: "commit",
    timeout: UI_TIMEOUT_MS,
  });
  await page.locator('[data-testid="mnote-trash-workbench"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
}

async function waitForVisible(page, selector, label) {
  await page.locator(selector).first().waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
    throw new Error(`${label} 未出现: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
  });
}

async function waitForDetached(page, selector, label) {
  await page.locator(selector).first().waitFor({ state: "detached", timeout: UI_TIMEOUT_MS }).catch(async (error) => {
    throw new Error(`${label} 未消失: ${error.message}; state=${JSON.stringify(await readPageState(page).catch(() => null))}`);
  });
}

async function installTreeEventRecorder(page, label, records) {
  await page.addInitScript(() => {
    window.__MNOTE_TASK433_TREE_EVENTS__ = [];
    const record = (name, event) => {
      const detail = event && event.detail ? event.detail : {};
      const payload = detail.payload || detail || {};
      window.__MNOTE_TASK433_TREE_EVENTS__.push({
        name,
        at: Date.now(),
        revision: detail.revision || payload.revision || payload.cursor || "",
        op: payload && payload.data && payload.data.op ? payload.data.op : "",
      });
    };
    window.addEventListener("tree:snapshot", (event) => record("tree:snapshot", event));
    window.addEventListener("tree:delta", (event) => record("tree:delta", event));
    window.addEventListener("tree:resync", (event) => record("tree:resync", event));
  });
  page.on("request", (request) => {
    const url = request.url();
    if (url.includes("/api/tree/events")) {
      records.push({ label, type: "request", method: request.method(), url, at: Date.now() });
    }
  });
  page.on("console", (message) => {
    const text = message.text();
    if (/tree live|EventSource|trash|error|failed/i.test(text)) {
      records.push({ label, type: "console", level: message.type(), text: text.slice(0, 2000), at: Date.now() });
    }
  });
}

function recordNavigation(page, label, records) {
  page.on("framenavigated", (frame) => {
    if (frame === page.mainFrame()) {
      records.push({ label, url: frame.url(), at: Date.now() });
    }
  });
}

async function readPageState(page) {
  return await page.evaluate(() => ({
    url: window.location.href,
    liveStatus: document.documentElement.getAttribute("data-mnote-tree-live-status") || "",
    liveApplied: document.documentElement.getAttribute("data-mnote-tree-live-applied") || "",
    liveRevision: document.documentElement.getAttribute("data-mnote-tree-live-revision") || "",
    liveError: document.documentElement.getAttribute("data-mnote-tree-live-apply-error") || "",
    trashLiveReason:
      document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-reason") || "",
    trashLiveAt:
      document.querySelector('[data-testid="mnote-trash-workbench"]')?.getAttribute("data-live-refresh-at") || "",
    filetreeRows: Array.from(document.querySelectorAll("#sidebar-file-tree-root .tree-row[data-shell-mode='filetree']")).map((row) => ({
      rowId: row instanceof HTMLElement ? row.dataset.rowId || "" : "",
      assetId: row instanceof HTMLElement ? row.dataset.assetId || "" : "",
      documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
      objectKind: row instanceof HTMLElement ? row.dataset.objectKind || "" : "",
      text: row.textContent || "",
    })),
    trashResourceRows: Array.from(document.querySelectorAll('[data-testid="mnote-trash-workbench"] [data-trash-row="resource"]')).map((row) => ({
      resourceKind: row instanceof HTMLElement ? row.dataset.resourceKind || "" : "",
      resourceId: row instanceof HTMLElement ? row.dataset.resourceId || "" : "",
      documentId: row instanceof HTMLElement ? row.dataset.documentId || "" : "",
      text: row.textContent || "",
    })),
    treeEvents: window.__MNOTE_TASK433_TREE_EVENTS__ || [],
  }));
}

async function snapshotStep(result, name, fileTreePage, trashPage, navigationStart) {
  result.steps.push({
    name,
    fileTree: await readPageState(fileTreePage),
    trash: await readPageState(trashPage),
    navigationEvents: result.navigationEvents.slice(navigationStart),
  });
}

async function cleanup(request, workspaceId, documentIds, assetIds) {
  for (const assetId of assetIds.filter(Boolean)) {
    await archiveAsset(request, assetId).catch(() => null);
  }
  if (workspaceId) {
    await emptyResourceTrash(request, workspaceId).catch(() => null);
  }
  for (const documentId of documentIds.filter(Boolean)) {
    await treeCommand(request, workspaceId, "archive", documentId).catch(() => null);
  }
  if (workspaceId) {
    await requestJson(request, "/api/documents/empty-trash", { method: "POST", data: { workspaceId } }).catch(() => null);
  }
}

(async () => {
  const stamp = Date.now();
  const email = `mnote.stage8.file.${stamp}@example.com`;
  const prefix = `TEST-10REVIEW-07-P8-FILE-${stamp}`;
  const result = {
    ok: false,
    task: TASK,
    baseUrl: BASE_URL,
    authBaseUrl: AUTH_BASE_URL,
    email,
    prefix,
    fixture: {},
    navigationEvents: [],
    treeEventRequests: [],
    steps: [],
  };

  const browser = await chromium.launch({ headless: true });
  const contextA = await browser.newContext();
  const contextB = await browser.newContext();
  const requestA = contextA.request;
  const fileTreeB = await contextB.newPage();
  const trashB = await contextB.newPage();
  let workspaceId = "";
  const cleanupDocumentIds = [];
  const cleanupAssetIds = [];

  await installTreeEventRecorder(fileTreeB, "B-filetree", result.treeEventRequests);
  await installTreeEventRecorder(trashB, "B-trash", result.treeEventRequests);
  recordNavigation(fileTreeB, "B-filetree", result.navigationEvents);
  recordNavigation(trashB, "B-trash", result.navigationEvents);

  try {
    for (const requestContext of [requestA, contextB.request]) {
      await authenticate(requestContext, email, `stage8-file-${stamp}`);
    }

    const root = await createPage(requestA, null, `${prefix}-root`);
    workspaceId = root.workspaceId;
    cleanupDocumentIds.push(root.documentId);
    result.fixture.rootId = root.documentId;
    result.fixture.workspaceId = workspaceId;

    await openDocumentFileTree(fileTreeB, workspaceId, root.documentId);
    await openTrash(trashB, workspaceId);
    await waitForVisible(fileTreeB, docRowSelector(root.documentId), "B 文件树 root 页面");
    const navigationStart = result.navigationEvents.length;
    await snapshotStep(result, "initial", fileTreeB, trashB, navigationStart);

    const uploaded = await uploadFileAsset(
      requestA,
      workspaceId,
      root.documentId,
      `${prefix}-lifecycle.txt`,
      `${prefix} lifecycle asset`,
    );
    cleanupAssetIds.push(uploaded.assetId);
    result.fixture.lifecycleAssetId = uploaded.assetId;
    await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树上传附件");
    await snapshotStep(result, "upload-visible-on-b", fileTreeB, trashB, navigationStart);

    await archiveAsset(requestA, uploaded.assetId);
    await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件删除后");
    await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件删除后");
    await snapshotStep(result, "archive-visible-on-b", fileTreeB, trashB, navigationStart);

    await restoreAsset(requestA, uploaded.assetId);
    await waitForVisible(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件恢复后");
    await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件恢复后");
    await snapshotStep(result, "restore-visible-on-b", fileTreeB, trashB, navigationStart);

    await archiveAsset(requestA, uploaded.assetId);
    await waitForVisible(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件再次删除后");
    await purgeAsset(requestA, uploaded.assetId);
    await waitForDetached(trashB, trashAssetRowSelector(uploaded.assetId), "B 垃圾箱附件彻底删除后");
    await waitForDetached(fileTreeB, assetRowSelector(uploaded.assetId), "B 文件树附件彻底删除后");
    await snapshotStep(result, "purge-visible-on-b", fileTreeB, trashB, navigationStart);

    const emptyAssetA = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-a.txt`, "empty a");
    const emptyAssetB = await uploadFileAsset(requestA, workspaceId, root.documentId, `${prefix}-empty-b.txt`, "empty b");
    cleanupAssetIds.push(emptyAssetA.assetId, emptyAssetB.assetId);
    result.fixture.emptyAssetIds = [emptyAssetA.assetId, emptyAssetB.assetId];
    await waitForVisible(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 上传后");
    await waitForVisible(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 上传后");
    await archiveAsset(requestA, emptyAssetA.assetId);
    await archiveAsset(requestA, emptyAssetB.assetId);
    await waitForVisible(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 删除后");
    await waitForVisible(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 删除后");
    await emptyResourceTrash(requestA, workspaceId);
    await waitForDetached(trashB, trashAssetRowSelector(emptyAssetA.assetId), "B 垃圾箱 empty-a 清空后");
    await waitForDetached(trashB, trashAssetRowSelector(emptyAssetB.assetId), "B 垃圾箱 empty-b 清空后");
    await waitForDetached(fileTreeB, assetRowSelector(emptyAssetA.assetId), "B 文件树 empty-a 清空后");
    await waitForDetached(fileTreeB, assetRowSelector(emptyAssetB.assetId), "B 文件树 empty-b 清空后");
    await snapshotStep(result, "empty-trash-visible-on-b", fileTreeB, trashB, navigationStart);

    const unexpectedNavigations = result.navigationEvents.slice(navigationStart);
    assert.equal(unexpectedNavigations.length, 0, `B 浏览器发生了刷新/导航: ${JSON.stringify(unexpectedNavigations)}`);
    result.ok = true;
  } catch (error) {
    result.error = error instanceof Error ? error.stack || error.message : String(error);
    result.failure = {
      fileTree: await readPageState(fileTreeB).catch(() => null),
      trash: await readPageState(trashB).catch(() => null),
    };
    process.exitCode = 1;
  } finally {
    await cleanup(requestA, workspaceId, cleanupDocumentIds, cleanupAssetIds).catch((error) => {
      result.cleanupError = error instanceof Error ? error.message : String(error);
    });
    await browser.close().catch(() => undefined);
    await writeResult(result);
  }
})();
