#!/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 TASK = "task518-onlyoffice-real-iframe-session-scope-smoke";
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 || 120_000);
const PROBE_DOCX_PATH = process.env.MNOTE_ONLYOFFICE_PROBE_DOCX
  || "/tmp/mnote-onlyoffice-manual/mnote-onlyoffice-upload-test.docx";
const OUT_DIR = path.join(process.cwd(), "tmp", TASK);
const SCREENSHOT_DIR = path.join(OUT_DIR, "screenshots");
const RESULT_PATH = path.join(OUT_DIR, "result.json");
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 localOfficeFileUrl(root, relativePath) {
  const url = new URL(`${BASE_URL}/api/local-folder/files/open`);
  url.searchParams.set("rootUri", fileUrl(root));
  url.searchParams.set("path", relativePath);
  return url.toString();
}

function onlyofficeUrl(root, relativePath, assetId) {
  const url = new URL(`${BASE_URL}/onlyoffice`);
  url.searchParams.set("fileUrl", localOfficeFileUrl(root, relativePath));
  url.searchParams.set("fileName", path.basename(relativePath));
  url.searchParams.set("fileType", "docx");
  url.searchParams.set("assetId", assetId);
  url.searchParams.set("documentId", "local-md:Page~2FPage.md");
  url.searchParams.set("mode", "edit");
  return url.toString();
}

function bridgeToolPayload(sessionId, assetId, allowedResourceIds, toolName = "mnote.onlyoffice.document.insert_text", options = {}) {
  return {
    toolName,
    workspaceId: "local-ws:task518",
    documentId: "local-md:Page~2FPage.md",
    actorId: "user_1",
    sessionId: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    runId: "task518-run",
    toolCallId: "task518-call",
    traceId: "task518-trace",
    idempotencyKey: `task518-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    dryRun: options.dryRun !== false,
    capabilityScope: ["office.write"],
    args: {
      onlyofficeSessionId: sessionId,
      text: options.text || "task518 dry run",
      aiAccessScope: {
        permissionLevel: "read_write",
        allowedResourceIds,
      },
    },
  };
}

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 !== "/auth", { timeout: UI_TIMEOUT_MS }).catch(() => undefined);
  }
}

async function waitForOfficeReady(page, label) {
  await page.waitForFunction(
    () => {
      const debug = window.__MNOTE_ONLYOFFICE_DEBUG__ || null;
      const frameCount = document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length;
      return Boolean(debug && debug.bridgeSessionId && debug.bridgeToken)
        && Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__)
        && (Boolean(window.__MNOTE_ONLYOFFICE_READY__) || frameCount > 0);
    },
    null,
    { timeout: UI_TIMEOUT_MS },
  );
  await page.screenshot({ path: path.join(SCREENSHOT_DIR, `${label}.png`), fullPage: true });
  return await page.evaluate(() => ({
    url: location.href,
    ready: Boolean(window.__MNOTE_ONLYOFFICE_READY__),
    editorExists: Boolean(window.__MNOTE_ONLYOFFICE_EDITOR__),
    frameCount: document.querySelectorAll("#onlyoffice-frame iframe, #onlyoffice-frame canvas").length,
    errorVisible: document.getElementById("onlyoffice-error")?.getAttribute("data-visible") || "",
    debug: window.__MNOTE_ONLYOFFICE_DEBUG__ || null,
    errorLog: Array.isArray(window.__MNOTE_ONLYOFFICE_ERRLOG__) ? window.__MNOTE_ONLYOFFICE_ERRLOG__.slice() : [],
  }));
}

async function readBridgeSessions(context) {
  const response = await context.request.fetch(`${BASE_URL}/api/onlyoffice/bridge/sessions`, {
    method: "GET",
    timeout: UI_TIMEOUT_MS,
  });
  const body = await response.json().catch(async () => ({ raw: await response.text() }));
  return {
    status: response.status(),
    body,
  };
}

async function waitForBridgeSession(context, sessionId) {
  const deadline = Date.now() + UI_TIMEOUT_MS;
  let last = null;
  while (Date.now() < deadline) {
    last = await readBridgeSessions(context);
    if (
      last.status === 200
      && Array.isArray(last.body?.sessions)
      && last.body.sessions.some((session) => session.sessionId === sessionId)
    ) {
      return last;
    }
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  throw new Error(`bridge session 未注册: ${sessionId}; last=${JSON.stringify(last)}`);
}

async function runBridgeSelection(page) {
  return await page.evaluate(async () => {
    return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("selection.get", {}, 30_000);
  });
}

async function runBridgeExport(page, format = "html") {
  return await page.evaluate(async (selectedFormat) => {
    return await window.__MNOTE_ONLYOFFICE_BRIDGE__.run("document.export", { format: selectedFormat }, 30_000);
  }, format);
}

async function postToolCall(context, payload) {
  const response = await context.request.fetch(`${BASE_URL}/api/hermes/tools/mnote/call`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-mnote-actor-id": "user_1",
    },
    data: payload,
    timeout: UI_TIMEOUT_MS,
  });
  const body = await response.json().catch(async () => ({ raw: await response.text() }));
  return {
    status: response.status(),
    headers: response.headers(),
    body,
  };
}

function classifyOnlyOfficeSignals(result, states, selections, scopeChecks) {
  const bridgeTranslation404 = result.httpErrors.filter((entry) => (
    /\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)
    && entry.status === 404
  ));
  const mnoteBridgePluginHttpErrors = result.httpErrors.filter((entry) => (
    /\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url)
    && !/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)
  ));
  const builtinPluginNoise = result.httpErrors.filter((entry) => (
    /\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url)
  ));
  const bridgeConsoleNoise = result.consoleErrors.filter((entry) => (
    /\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.text)
    || /Failed to load resource: the server responded with a status of 404/i.test(entry.text)
  ));
  const errorCodeMinus18 = [
    ...result.consoleErrors.map((entry) => entry.text || ""),
    ...states.flatMap((state) => Array.isArray(state.errorLog) ? state.errorLog.map((item) => JSON.stringify(item)) : []),
  ].some((text) => /errorCode["'=:\s-]*-18|\b-18\b/.test(text));
  const mainDocumentFailures = [
    ...result.networkFailures.filter((entry) => (
      !/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url)
    )),
    ...result.httpErrors.filter((entry) => {
      if (/\/api\/onlyoffice\/bridge\/plugin\/index\/translations\//.test(entry.url)) return false;
      if (/\/sdkjs-plugins\/|custom.?assistant|annotation/i.test(entry.url)) return false;
      if (/\/api\/onlyoffice\/bridge\/plugin\//.test(entry.url)) return false;
      if (/\/api\/onlyoffice\/bridge\/commands\/next/.test(entry.url)) return false;
      return /\/onlyoffice-server\/|\/api\/onlyoffice\/proxy|\/api\/local-folder\/files\/open/.test(entry.url);
    }),
    ...states.filter((state) => state.errorVisible || !state.editorExists || !state.ready).map((state) => ({
      url: state.url,
      ready: state.ready,
      editorExists: state.editorExists,
      errorVisible: state.errorVisible,
    })),
  ];
  return {
    mnoteBridgePlugin: {
      configUrls: states.map((state) => state.debug?.bridgePluginConfigUrl || ""),
      httpErrors: mnoteBridgePluginHttpErrors,
      sessionsRegistered: states.every((state) => (
        result.bridgeSessionsFinal?.body?.sessions || []
      ).some((session) => session.sessionId === state.debug?.bridgeSessionId)),
      commandLoopOk: scopeChecks?.allowedBDryRun?.status === 200
        && scopeChecks?.allowedBWrite?.status === 200,
    },
    bridgeTranslationNoise: {
      count: bridgeTranslation404.length,
      urls: Array.from(new Set(bridgeTranslation404.map((entry) => entry.url))),
    },
    builtinPluginNoise: {
      count: builtinPluginNoise.length,
      urls: Array.from(new Set(builtinPluginNoise.map((entry) => entry.url))),
    },
    mainDocument: {
      ready: mainDocumentFailures.length === 0 && selections.every((selection) => selection?.editorType === "word"),
      failures: mainDocumentFailures,
      errorCodeMinus18,
    },
    consoleNoise: {
      bridgeTranslationOrGeneric404Count: bridgeConsoleNoise.length,
    },
  };
}

async function main() {
  assert(fs.existsSync(PROBE_DOCX_PATH), `缺少探测 docx: ${PROBE_DOCX_PATH}`);
  fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });

  const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task518-onlyoffice-"));
  fs.mkdirSync(path.join(root, "Page"), { recursive: true });
  fs.writeFileSync(path.join(root, ".mnote-placeholder"), "task518\n", "utf8");
  fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-a.docx"));
  fs.copyFileSync(PROBE_DOCX_PATH, path.join(root, "Page", "office-b.docx"));

  const consoleErrors = [];
  const networkFailures = [];
  const httpErrors = [];
  const browser = await chromium.launch({
    headless: process.env.HEADFUL !== "1",
    executablePath: CHROMIUM_EXECUTABLE_PATH,
  });
  const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
  const authPage = await context.newPage();

  const result = {
    ok: false,
    task: TASK,
    baseUrl: BASE_URL,
    root,
    screenshots: [],
    consoleErrors,
    networkFailures,
    httpErrors,
  };

  try {
    await quickLogin(authPage);
    await authPage.close();

    const pageA = await context.newPage();
    const pageADuplicate = await context.newPage();
    const pageB = await context.newPage();
    for (const page of [pageA, pageADuplicate, pageB]) {
      page.on("console", (message) => {
        if (message.type() === "error") consoleErrors.push({ url: page.url(), text: message.text() });
      });
      page.on("requestfailed", (request) => {
        networkFailures.push({ url: request.url(), failure: request.failure()?.errorText || "" });
      });
      page.on("response", (response) => {
        if (response.status() >= 400) {
          httpErrors.push({ url: response.url(), status: response.status() });
        }
      });
    }

    const assetA = "local-file:Page/office-a.docx";
    const assetB = "local-file:Page/office-b.docx";
    await pageA.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), {
      waitUntil: "domcontentloaded",
      timeout: UI_TIMEOUT_MS,
    });
    await pageADuplicate.goto(onlyofficeUrl(root, "Page/office-a.docx", assetA), {
      waitUntil: "domcontentloaded",
      timeout: UI_TIMEOUT_MS,
    });
    await pageB.goto(onlyofficeUrl(root, "Page/office-b.docx", assetB), {
      waitUntil: "domcontentloaded",
      timeout: UI_TIMEOUT_MS,
    });

    const stateA = await waitForOfficeReady(pageA, "office-a");
    const stateADuplicate = await waitForOfficeReady(pageADuplicate, "office-a-duplicate");
    const stateB = await waitForOfficeReady(pageB, "office-b");
    result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a.png"));
    result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-duplicate.png"));
    result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b.png"));
    assert.equal(stateA.errorVisible, "", `A 页面不应显示错误: ${JSON.stringify(stateA)}`);
    assert.equal(stateADuplicate.errorVisible, "", `A duplicate 页面不应显示错误: ${JSON.stringify(stateADuplicate)}`);
    assert.equal(stateB.errorVisible, "", `B 页面不应显示错误: ${JSON.stringify(stateB)}`);
    assert(stateA.debug.bridgeSessionId !== stateB.debug.bridgeSessionId, `A/B sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, b: stateB.debug })}`);
    assert(
      stateA.debug.bridgeSessionId !== stateADuplicate.debug.bridgeSessionId,
      `同一 Office 文档双 tab sessionId 不应相同: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`,
    );
    assert.equal(stateA.debug.docKey, stateADuplicate.debug.docKey, `同一 Office 文档双 tab 应共享 docKey 但使用不同 session salt: ${JSON.stringify({ a: stateA.debug, duplicate: stateADuplicate.debug })}`);
    assert.equal(stateA.debug.assetId, assetA, `A debug assetId 不匹配: ${JSON.stringify(stateA.debug)}`);
    assert.equal(stateADuplicate.debug.assetId, assetA, `A duplicate debug assetId 不匹配: ${JSON.stringify(stateADuplicate.debug)}`);
    assert.equal(stateB.debug.assetId, assetB, `B debug assetId 不匹配: ${JSON.stringify(stateB.debug)}`);

    result.officeAInitial = stateA;
    result.officeADuplicateInitial = stateADuplicate;
    result.officeBInitial = stateB;
    result.bridgeSessionsAfterReady = await readBridgeSessions(context);
    await waitForBridgeSession(context, stateA.debug.bridgeSessionId);
    await waitForBridgeSession(context, stateADuplicate.debug.bridgeSessionId);
    await waitForBridgeSession(context, stateB.debug.bridgeSessionId);
    result.bridgeSessionsFinal = await readBridgeSessions(context);
    const selectionA = await runBridgeSelection(pageA);
    const selectionADuplicate = await runBridgeSelection(pageADuplicate);
    const selectionB = await runBridgeSelection(pageB);
    assert.equal(selectionA.editorType, "word", `A selection 应来自 word editor: ${JSON.stringify(selectionA)}`);
    assert.equal(selectionADuplicate.editorType, "word", `A duplicate selection 应来自 word editor: ${JSON.stringify(selectionADuplicate)}`);
    assert.equal(selectionB.editorType, "word", `B selection 应来自 word editor: ${JSON.stringify(selectionB)}`);

    const forbiddenBFromScopeA = await postToolCall(
      context,
      bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetA, `resource:onlyoffice:${stateA.debug.documentId}:${assetA}`]),
    );
    assert.equal(forbiddenBFromScopeA.status, 403, JSON.stringify(forbiddenBFromScopeA));
    assert.equal(forbiddenBFromScopeA.headers["x-error-code"], "mnote_onlyoffice_resource_scope_forbidden");

    const allowedBDryRun = await postToolCall(
      context,
      bridgeToolPayload(stateB.debug.bridgeSessionId, assetB, [assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`]),
    );
    assert.equal(allowedBDryRun.status, 200, JSON.stringify(allowedBDryRun));
    assert.equal(allowedBDryRun.body.result.schema, "mnote.onlyoffice.action_plan.v1");
    assert.equal(allowedBDryRun.body.result.sessionId, stateB.debug.bridgeSessionId);

    const writeMarker = `task518-write-${Date.now()}-${Math.random().toString(36).slice(2)}`;
    const allowedBWrite = await postToolCall(
      context,
      bridgeToolPayload(
        stateB.debug.bridgeSessionId,
        assetB,
        [assetB, `resource:onlyoffice:${stateB.debug.documentId}:${assetB}`],
        "mnote.onlyoffice.document.insert_text",
        { dryRun: false, text: writeMarker },
      ),
    );
    assert.equal(allowedBWrite.status, 200, JSON.stringify(allowedBWrite));
    assert.equal(allowedBWrite.body.result.schema, "mnote.onlyoffice.action_result.v1");
    assert.equal(allowedBWrite.body.result.sessionId, stateB.debug.bridgeSessionId);
    await pageA.screenshot({ path: path.join(SCREENSHOT_DIR, "office-a-after-write.png"), fullPage: true });
    await pageB.screenshot({ path: path.join(SCREENSHOT_DIR, "office-b-after-write.png"), fullPage: true });
    result.screenshots.push(path.join(SCREENSHOT_DIR, "office-a-after-write.png"));
    result.screenshots.push(path.join(SCREENSHOT_DIR, "office-b-after-write.png"));

    const exportAAfterWrite = await runBridgeExport(pageA, "html");
    const exportBAfterWrite = await runBridgeExport(pageB, "html");
    const exportAText = String(exportAAfterWrite?.content || exportAAfterWrite?.result?.content || "");
    const exportBText = String(exportBAfterWrite?.content || exportBAfterWrite?.result?.content || "");
    assert(
      exportBText.includes(writeMarker),
      `B 导出内容应包含写入标记 ${writeMarker}: ${JSON.stringify(exportBAfterWrite).slice(0, 1000)}`,
    );
    assert(
      !exportAText.includes(writeMarker),
      `A 导出内容不应包含 B 写入标记 ${writeMarker}: ${JSON.stringify(exportAAfterWrite).slice(0, 1000)}`,
    );

    const scopeChecks = {
      forbiddenBFromScopeA: {
        status: forbiddenBFromScopeA.status,
        code: forbiddenBFromScopeA.headers["x-error-code"],
      },
      allowedBDryRun: {
        status: allowedBDryRun.status,
        schema: allowedBDryRun.body.result.schema,
        sessionId: allowedBDryRun.body.result.sessionId,
      },
      allowedBWrite: {
        status: allowedBWrite.status,
        schema: allowedBWrite.body.result.schema,
        sessionId: allowedBWrite.body.result.sessionId,
        commandId: allowedBWrite.body.result.commandId,
      },
      exportAfterWrite: {
        writeMarker,
        aContainsMarker: exportAText.includes(writeMarker),
        bContainsMarker: exportBText.includes(writeMarker),
      },
    };
    result.bridgeSessionsFinal = await readBridgeSessions(context);
    const errorClassification = classifyOnlyOfficeSignals(
      result,
      [stateA, stateADuplicate, stateB],
      [selectionA, selectionADuplicate, selectionB],
      scopeChecks,
    );
    assert.equal(
      errorClassification.mnoteBridgePlugin.httpErrors.length,
      0,
      `MNote bridge 插件 config/index 失败不能归为普通噪音: ${JSON.stringify(errorClassification.mnoteBridgePlugin.httpErrors)}`,
    );
    assert.equal(
      errorClassification.mnoteBridgePlugin.sessionsRegistered,
      true,
      `MNote bridge 插件应完成 session 注册: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`,
    );
    assert.equal(
      errorClassification.mnoteBridgePlugin.commandLoopOk,
      true,
      `MNote bridge 插件 command loop 应可用: ${JSON.stringify(errorClassification.mnoteBridgePlugin)}`,
    );
    assert.equal(
      errorClassification.mainDocument.errorCodeMinus18,
      false,
      `errorCode=-18 必须作为主文档失败暴露: ${JSON.stringify(errorClassification.mainDocument)}`,
    );
    assert.equal(
      errorClassification.mainDocument.ready,
      true,
      `主文档失败不能被插件噪音吞掉: ${JSON.stringify(errorClassification.mainDocument)}`,
    );

    Object.assign(result, {
      ok: true,
      errorClassification,
      officeA: {
        sessionId: stateA.debug.bridgeSessionId,
        assetId: stateA.debug.assetId,
        docKey: stateA.debug.docKey,
        frameCount: stateA.frameCount,
        selection: selectionA,
      },
      officeADuplicate: {
        sessionId: stateADuplicate.debug.bridgeSessionId,
        assetId: stateADuplicate.debug.assetId,
        docKey: stateADuplicate.debug.docKey,
        frameCount: stateADuplicate.frameCount,
        selection: selectionADuplicate,
      },
      officeB: {
        sessionId: stateB.debug.bridgeSessionId,
        assetId: stateB.debug.assetId,
        docKey: stateB.debug.docKey,
        frameCount: stateB.frameCount,
        selection: selectionB,
      },
      scopeChecks,
    });
    fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
    console.log(JSON.stringify(result, null, 2));
  } catch (error) {
    result.error = error && error.stack ? error.stack : String(error);
    fs.writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8");
    throw error;
  } finally {
    await context.close().catch(() => undefined);
    await browser.close().catch(() => undefined);
  }
}

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