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

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

const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const PNG_MIME = "image/png";
const TEST_EMAIL = "mnote.e2e@example.com";
const TEST_PASSWORD = "MnoteE2E123!";
// 说明：该 smoke 仍在验证 Convex media / OnlyOffice 的 cloud upload 兼容入口，不是 local-first 默认上传路径。
const PROBE_DOCX_PATH =
  process.env.MNOTE_ONLYOFFICE_PROBE_DOCX ||
  "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const SCREENSHOT_DIR =
  process.env.MNOTE_UPLOAD_ENTRY_SCREENSHOT_DIR ||
  "/mnt/Data1T/mnote/tmp/wolai-editor-parity/task175-rust-upload-entry";

function ensureProbeDocx() {
  assert(fs.existsSync(PROBE_DOCX_PATH), `缺少 Office 探测文件：${PROBE_DOCX_PATH}`);
  return {
    name: "task175-slash-attachment.docx",
    mimeType: DOCX_MIME,
    buffer: fs.readFileSync(PROBE_DOCX_PATH),
  };
}

function tinyPngBuffer() {
  return Buffer.from(
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
    "base64",
  );
}

function parseSetCookie(setCookie, origin) {
  const [nameValue] = String(setCookie || "").split(";");
  const separator = nameValue.indexOf("=");
  if (separator <= 0) return null;
  return {
    name: nameValue.slice(0, separator).trim(),
    value: nameValue.slice(separator + 1).trim(),
    domain: new URL(origin).hostname,
    path: "/",
    httpOnly: /;\s*httponly\b/i.test(setCookie),
    sameSite: "Lax",
  };
}

async function forceTestAccountLogin(context) {
  const response = await context.request.fetch(`${BASE_URL}/api/auth`, {
    method: "POST",
    data: {
      action: "auth:signIn",
      args: {
        provider: "password",
        params: {
          email: TEST_EMAIL,
          password: TEST_PASSWORD,
          flow: "signIn",
        },
      },
    },
    headers: { "content-type": "application/json" },
    timeout: UI_TIMEOUT_MS,
  });
  const payload = await response.json().catch(async () => ({ raw: await response.text() }));
  assert(response.ok(), `测试账号登录失败：${response.status()} ${JSON.stringify(payload)}`);
  const cookies = response
    .headersArray()
    .filter((header) => header.name.toLowerCase() === "set-cookie")
    .map((header) => parseSetCookie(header.value, BASE_URL))
    .filter(Boolean);
  assert(cookies.length > 0, "测试账号登录响应缺少 set-cookie");
  await context.addCookies(cookies);
}

async function waitForRuntimeEditor(page) {
  const editor = page
    .locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror[contenteditable="true"]')
    .first();
  await editor.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  return editor;
}

async function waitForUploadResponse(page, fileName, action) {
  const response = await page.waitForResponse(
    async (candidate) => {
      if (!candidate.url().includes("/api/media/upload") || candidate.request().method() !== "POST") {
        return false;
      }
      const payload = await candidate.json().catch(() => null);
      return Boolean(payload?.asset?.id && (!fileName || payload.asset.file_name === fileName));
    },
    { timeout: UI_TIMEOUT_MS },
  );
  const payload = await response.json();
  assert(response.ok(), `${action} 上传失败：${response.status()} ${JSON.stringify(payload)}`);
  assert(payload?.asset?.id, `${action} 上传响应缺少 asset.id：${JSON.stringify(payload)}`);
  return payload.asset;
}

async function waitForAssetRow(page, assetId, action) {
  try {
    await page.waitForFunction(
      (targetAssetId) =>
        Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).some(
          (row) =>
            row instanceof HTMLElement &&
            window.getComputedStyle(row).display !== "none" &&
            window.getComputedStyle(row).visibility !== "hidden" &&
            row.getClientRects().length > 0,
        ),
      assetId,
      { timeout: UI_TIMEOUT_MS },
    );
  } catch (error) {
    const debug = await page.evaluate((targetAssetId) => ({
      mode: document.documentElement.getAttribute("data-mnote-sidebar-tree-mode"),
      lastUpload: document.documentElement.getAttribute("data-mnote-last-upload-asset-id"),
      targetCount: document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`).length,
      assets: Array.from(document.querySelectorAll('[data-testid="filetree-asset-row"]')).slice(-12).map((row) => ({
        id: row.getAttribute("data-asset-id"),
        visible: row instanceof HTMLElement && window.getComputedStyle(row).display !== "none" && window.getComputedStyle(row).visibility !== "hidden" && row.getClientRects().length > 0,
        text: row.textContent,
      })),
      body: document.body.innerText.slice(0, 1200),
    }), assetId).catch((debugError) => ({ debugError: String(debugError) }));
    throw new Error(`${action} 上传后的文件树附件行不可见：${assetId} ${JSON.stringify(debug)}`);
  }
  const title = await page.evaluate((targetAssetId) => {
    const row = Array.from(document.querySelectorAll(`[data-testid="filetree-asset-row"][data-asset-id="${targetAssetId}"]`)).find(
      (candidate) =>
        candidate instanceof HTMLElement &&
        window.getComputedStyle(candidate).display !== "none" &&
        window.getComputedStyle(candidate).visibility !== "hidden" &&
        candidate.getClientRects().length > 0,
    );
    return row?.querySelector(".tree-link-title")?.textContent?.trim() || "";
  }, assetId);
  assert(title.length > 0, `${action} 上传后的文件树附件行缺少标题`);
}

async function waitForEditorOfficeAttachment(page, assetId, fileName, action) {
  const attachment = page
    .locator(`.editor-surface .ProseMirror a[data-mnote-attachment-link="true"][href*="/onlyoffice"][href*="${assetId}"]`)
    .first();
  await attachment.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  const text = (await attachment.innerText()).trim();
  assert(text.includes(fileName), `${action} 正文附件标题不正确：${text}`);
  const href = await attachment.getAttribute("href");
  assert(href && href.includes("/onlyoffice?"), `${action} 正文 Office 附件 href 应指向 /onlyoffice，实际：${href}`);
  assert(
    href && href.startsWith("/onlyoffice?"),
    `${action} 正文 Office 附件 href 应保存为相对 /onlyoffice 链接，实际：${href}`,
  );
  assert(href.includes(`assetId=${encodeURIComponent(assetId)}`), `${action} 正文 Office 附件 href 缺少 assetId：${href}`);
  return attachment;
}

async function assertAttachmentActions(page, attachment, action) {
  await attachment.hover({ timeout: UI_TIMEOUT_MS });
  const actions = page.locator('[data-testid="mnote-attachment-actions"]').first();
  await actions.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await actions.locator('[data-testid="mnote-attachment-action-menu"]').click({ timeout: UI_TIMEOUT_MS });
  const menu = page.locator('[data-testid="mnote-tree-context-menu"][data-kind="attachment"]').first();
  await menu.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  const text = await menu.innerText();
  for (const label of ["拷贝副本", "删除", "复制链接", "弹窗预览", "右侧预览", "下载", "更换文件", "重命名", "添加说明文字"]) {
    assert(text.includes(label), `${action} 附件三点菜单缺少“${label}”：${text}`);
  }
  await page.keyboard.press("Escape").catch(() => undefined);
}

async function screenshot(page, name) {
  fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
  await page.screenshot({
    path: path.join(SCREENSHOT_DIR, `${name}.png`),
    fullPage: false,
    timeout: UI_TIMEOUT_MS,
  });
}

async function uploadViaSlash(page, itemTestId, filePayload, action) {
  const editor = await waitForRuntimeEditor(page);
  await editor.click({ timeout: UI_TIMEOUT_MS });
  await page.keyboard.type("/");
  const slash = page.locator('[data-testid="mnote-leptos-tiptap-slash-menu"]').first();
  await slash.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  const slashText = await slash.innerText();
  assert(slashText.includes("媒体与附件"), `slash 菜单缺少媒体与附件分组：${slashText}`);

  const item = page.locator(`[data-testid="${itemTestId}"]`).first();
  await item.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
  await item.scrollIntoViewIfNeeded({ timeout: UI_TIMEOUT_MS });
  await screenshot(page, `slash-${itemTestId}`);

  const [fileChooser] = await Promise.all([
    page.waitForEvent("filechooser", { timeout: UI_TIMEOUT_MS }),
    item.click({ timeout: UI_TIMEOUT_MS }),
  ]);
  const uploadResponse = waitForUploadResponse(page, filePayload.name, action);
  await fileChooser.setFiles(filePayload);
  const asset = await uploadResponse;
  await waitForAssetRow(page, asset.id, action);
  return asset;
}

async function dispatchFileDrop(page, selector, filePayload, action) {
  const uploadPromise = waitForUploadResponse(page, filePayload.name, action);
  await page.evaluate(
    ({ selector, fileName, mimeType, bytes }) => {
      const target = Array.from(document.querySelectorAll(selector)).find(
        (candidate) => candidate instanceof HTMLElement && candidate.getClientRects().length > 0,
      ) || document.querySelector(selector);
      if (!(target instanceof HTMLElement)) {
        throw new Error(`拖放目标不存在：${selector}`);
      }
      const dataTransfer = new DataTransfer();
      dataTransfer.items.add(new File([new Uint8Array(bytes)], fileName, { type: mimeType }));
      target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
      target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
    },
    {
      selector,
      fileName: filePayload.name,
      mimeType: filePayload.mimeType,
      bytes: Array.from(filePayload.buffer),
    },
  );
  const asset = await uploadPromise;
  await waitForAssetRow(page, asset.id, action);
  return asset;
}

async function main() {
  const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1" });
  const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
  const page = await context.newPage();
  const networkNotes = [];
  page.on("response", async (response) => {
    if (!/\/api\/(media\/upload|tree\/filetree\/upload-target-preflight)/.test(response.url())) return;
    const text = await response.text().catch(() => "");
    networkNotes.push({
      status: response.status(),
      url: response.url(),
      body: text.slice(0, 1000),
    });
  });
  page.on("pageerror", (error) => {
    networkNotes.push({ type: "pageerror", message: error.message });
  });
  page.on("console", (message) => {
    if (["error", "warning"].includes(message.type())) {
      networkNotes.push({ type: message.type(), message: message.text().slice(0, 1000) });
    }
  });
  page.on("dialog", async (dialog) => {
    networkNotes.push({ type: "dialog", message: dialog.message() });
    await dialog.dismiss().catch(() => undefined);
  });
  let target = null;

  try {
    await forceTestAccountLogin(context);
    await ensureAuthenticated(page, context.request);
    target = await createTempDocument(context.request, null);
    await openDocument(page, target.workspaceId, target.documentId);
    await openFilesystemView(page);

    const slashAttachment = ensureProbeDocx();
    const slashImage = {
      name: "task175-slash-image.png",
      mimeType: PNG_MIME,
      buffer: tinyPngBuffer(),
    };
    const treeDropAttachment = {
      name: "task175-filetree-drop.docx",
      mimeType: DOCX_MIME,
      buffer: slashAttachment.buffer,
    };
    const editorDropImage = {
      name: "task175-editor-drop.png",
      mimeType: PNG_MIME,
      buffer: tinyPngBuffer(),
    };

    const attachmentAsset = await uploadViaSlash(
      page,
      "slash-item-upload-attachment",
      slashAttachment,
      "slash 上传附件",
    );
    const editorAttachment = await waitForEditorOfficeAttachment(
      page,
      attachmentAsset.id,
      slashAttachment.name,
      "slash 上传附件",
    );
    await assertAttachmentActions(page, editorAttachment, "slash 上传附件");
    await page.reload({ waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
    await waitForRuntimeEditor(page);
    await waitForEditorOfficeAttachment(page, attachmentAsset.id, slashAttachment.name, "刷新后正文附件");
    await openFilesystemView(page);
    const imageAsset = await uploadViaSlash(page, "slash-item-image", slashImage, "slash 上传图片");

    const docRowSelector = `[data-testid="filetree-doc-row"][data-document-id="${target.documentId}"]`;
    const droppedAttachment = await dispatchFileDrop(page, docRowSelector, treeDropAttachment, "文件树拖入附件");

    const editorStageSelector = '[data-testid="mnote-leptos-tiptap-editor-stage"]';
    const droppedImage = await dispatchFileDrop(page, editorStageSelector, editorDropImage, "主编辑区拖入图片");
    await page.waitForFunction(
      (fileName) => {
        const images = Array.from(document.querySelectorAll(".editor-surface .ProseMirror img[src]"));
        return images.some((image) => image.getAttribute("alt") === fileName || image.getAttribute("title") === fileName);
      },
      editorDropImage.name,
      { timeout: UI_TIMEOUT_MS },
    );

    console.log(
      JSON.stringify(
        {
          ok: true,
          workspaceId: target.workspaceId,
          documentId: target.documentId,
          uploadedAssetIds: [
            attachmentAsset.id,
            imageAsset.id,
            droppedAttachment.id,
            droppedImage.id,
          ],
          screenshotDir: SCREENSHOT_DIR,
        },
        null,
        2,
      ),
    );
  } catch (error) {
    if (networkNotes.length) {
      console.error(`上传入口调试信息：${JSON.stringify(networkNotes.slice(-20), null, 2)}`);
    }
    throw error;
  } finally {
    if (target?.documentId) {
      await purgeDocument(context.request, target.documentId).catch((error) => {
        console.warn(`清理临时页面失败：${error instanceof Error ? error.message : String(error)}`);
      });
    }
    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);
});
