#!/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 { execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const {
  BASE_URL,
  UI_TIMEOUT_MS,
  ensureAuthenticated,
} = require("./tree-shell-smoke-helpers");

const TASK = "task512-chatonly-doubao-sync-smoke";
const OUTPUT_DIR = path.join(process.cwd(), "tmp", TASK);
const RESULT_PATH = path.join(OUTPUT_DIR, "result.json");
const CONTROL_PLANE_DB = "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const DOUBAO_CDP_URL = process.env.MNOTE_DOUBAO_CDP_URL || "http://127.0.0.1:9233";
const CHROMIUM_EXECUTABLE_PATH = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
  || ["/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/snap/bin/chromium", "/usr/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 writeWorkspaceManifest(root, ownerId, workspaceId) {
  fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
  fs.writeFileSync(
    path.join(root, ".mnote", "workspace.json"),
    `${JSON.stringify({
      workspaceId,
      ownerId,
      createdAt: new Date().toISOString(),
      capabilities: ["local_files", "ai_sessions", "markdown_edit"],
    }, null, 2)}\n`,
    "utf8",
  );
}

function sqliteJson(sql, fallback = null) {
  try {
    const raw = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "pipe"],
    }).trim();
    return raw ? JSON.parse(raw) : fallback;
  } catch {
    return fallback;
  }
}

function sqlQuote(value) {
  return `'${String(value).replaceAll("'", "''")}'`;
}

function sqliteExec(sql) {
  execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });
}

function grantWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) {
  const now = new Date().toISOString();
  sqliteExec(`
    INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
    VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
    INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
    VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task512 Doubao Smoke', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
    INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
    VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai","markdown_edit"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1);
  `);
}

async function firstDoubaoPage(cdpBrowser) {
  for (const context of cdpBrowser.contexts()) {
    const page = context.pages().find((candidate) => candidate.url().includes("doubao.com"));
    if (page) return page;
  }
  const context = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
  const page = await context.newPage();
  await page.goto("https://www.doubao.com/chat/", { waitUntil: "domcontentloaded" });
  return page;
}

async function installDoubaoFetchProbe(page) {
  await page.evaluate(() => {
    const win = window;
    const shouldTrack = (url) => url.includes("/samantha/chat/completion")
      || url.includes("/im/conversation/batch_del_user_conv");
    if (!win.__mnoteDoubaoOriginalFetch) {
      win.__mnoteDoubaoOriginalFetch = win.fetch.bind(win);
    }
    win.__mnoteDoubaoFetchLog = [];
    win.fetch = async function patchedMnoteDoubaoFetch(input, init) {
      const url = String((input && input.url) || input || "");
      const method = String((init && init.method) || "GET").toUpperCase();
      if (shouldTrack(url)) {
        win.__mnoteDoubaoFetchLog.push({
          transport: "fetch",
          url,
          method,
          body: init && init.body ? String(init.body) : "",
          ts: Date.now(),
        });
      }
      return win.__mnoteDoubaoOriginalFetch(input, init);
    };
    if (!win.__mnoteDoubaoOriginalXHROpen && win.XMLHttpRequest) {
      win.__mnoteDoubaoOriginalXHROpen = win.XMLHttpRequest.prototype.open;
      win.__mnoteDoubaoOriginalXHRSend = win.XMLHttpRequest.prototype.send;
      win.XMLHttpRequest.prototype.open = function patchedMnoteDoubaoXHROpen(method, url, ...rest) {
        this.__mnoteDoubaoProbeMethod = String(method || "GET").toUpperCase();
        this.__mnoteDoubaoProbeUrl = String(url || "");
        return win.__mnoteDoubaoOriginalXHROpen.call(this, method, url, ...rest);
      };
      win.XMLHttpRequest.prototype.send = function patchedMnoteDoubaoXHRSend(body) {
        const url = String(this.__mnoteDoubaoProbeUrl || "");
        if (shouldTrack(url)) {
          win.__mnoteDoubaoFetchLog.push({
            transport: "xhr",
            url,
            method: String(this.__mnoteDoubaoProbeMethod || "GET").toUpperCase(),
            body: body ? String(body) : "",
            ts: Date.now(),
          });
        }
        return win.__mnoteDoubaoOriginalXHRSend.call(this, body);
      };
    }
  });
}

async function readDoubaoFetchLog(page) {
  return await page.evaluate(() => Array.isArray(window.__mnoteDoubaoFetchLog)
    ? window.__mnoteDoubaoFetchLog
    : []);
}

async function readDoubaoConversationStats(page, { marker, prompt }) {
  return await page.evaluate(({ marker, prompt }) => {
    const normalize = (value) => String(value || "").replace(/\s+/g, " ").trim();
    const markerText = String(marker || "");
    const promptText = String(prompt || "");
    const userBubbleTexts = Array.from(document.querySelectorAll(".bg-g-send-msg-bubble-bg"))
      .map((node) => normalize(node.textContent));
    const assistantMarkdownTexts = Array.from(document.querySelectorAll(".md-box-root"))
      .map((node) => normalize(node.textContent));
    return {
      userPromptCount: userBubbleTexts.filter((text) => text === normalize(promptText)).length,
      userMarkerCount: userBubbleTexts.filter((text) => text.includes(markerText)).length,
      assistantMarkerCount: assistantMarkdownTexts.filter((text) => text.includes(markerText)).length,
      userBubbleTexts,
      assistantMarkdownTexts,
    };
  }, { marker, prompt });
}

async function saveScreenshot(page, name) {
  const target = path.join(OUTPUT_DIR, `${name}.png`);
  await page.screenshot({ path: target, fullPage: false });
  return target;
}

function doubaoConversationUrl(remoteConversationId) {
  return `https://www.doubao.com/chat/${encodeURIComponent(remoteConversationId)}`;
}

async function captureDoubaoPage(page, { name, url, remoteConversationId = "" }) {
  let navigationError = "";
  if (url) {
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((error) => {
      navigationError = error instanceof Error ? error.message : String(error);
    });
  }
  await page.bringToFront().catch(() => {});
  await page.waitForTimeout(2500);
  const screenshot = await saveScreenshot(page, name);
  const title = await page.title().catch(() => "");
  const pageUrl = page.url();
  const visibleText = await page.locator("body").innerText({ timeout: 5000 }).catch(() => "");
  const conversationRowCount = remoteConversationId
    ? await page.locator(`#conversation_${remoteConversationId}`).count().catch(() => -1)
    : null;
  return {
    screenshot,
    requestedUrl: url || "",
    pageUrl,
    title,
    navigationError,
    conversationRowCount,
    visibleText: visibleText.slice(0, 2000),
  };
}

async function main() {
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  const suffix = Date.now().toString(36);
  const actorId = "mnote-e2e";
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task512-doubao-"));
  const rootUri = fileUrl(root);
  const workspaceId = `local-ws:${actorId}:task512-${suffix}`;
  const relativePath = "DoubaoChatOnlySync.md";
  const marker = `MNOTE_DOUBAO_SYNC_${suffix.toUpperCase()}`;
  const prompt = `请只回复以下字符串，不要添加空格或其他内容：${marker}`;
  const screenshots = {};
  const providerCaptures = {};
  const runRequests = [];
  const deleteResponses = [];
  let caughtError = null;

  writeWorkspaceManifest(root, actorId, workspaceId);
  fs.writeFileSync(path.join(root, relativePath), ["# Doubao ChatOnly Sync", "", marker, ""].join("\n"), "utf8");
  grantWorkspaceAccess({
    actorId,
    workspaceId,
    root,
    rootUri,
    grantId: `grant_task512_${suffix}`,
  });

  const doubaoBrowser = await chromium.connectOverCDP(DOUBAO_CDP_URL);
  const doubaoPage = await firstDoubaoPage(doubaoBrowser);
  await doubaoPage.goto("https://www.doubao.com/chat/", {
    waitUntil: "domcontentloaded",
    timeout: 60_000,
  }).catch(() => {});
  await doubaoPage.keyboard.press("Escape").catch(() => {});
  await installDoubaoFetchProbe(doubaoPage);

  const browser = await chromium.launch({
    headless: true,
    ...(CHROMIUM_EXECUTABLE_PATH ? { executablePath: CHROMIUM_EXECUTABLE_PATH } : {}),
  });
  const context = await browser.newContext({
    viewport: { width: 1440, height: 960 },
    locale: "zh-CN",
    extraHTTPHeaders: {
      "x-mnote-actor-id": actorId,
      "x-mnote-actor-type": "user",
    },
  });
  const page = await context.newPage();

  try {
    await page.route("**/api/hermes/client/runs", async (route) => {
      runRequests.push(JSON.parse(route.request().postData() || "{}"));
      await route.continue();
    });
    page.on("response", async (response) => {
      const url = response.url();
      const request = response.request();
      if (request.method() === "DELETE" && url.includes("/api/hermes/client/sessions/")) {
        deleteResponses.push({
          url,
          status: response.status(),
          body: await response.text().catch(() => ""),
        });
      }
    });

    await ensureAuthenticated(page, context.request);
    const response = await page.goto(documentUrl(root, relativePath), {
      waitUntil: "domcontentloaded",
      timeout: UI_TIMEOUT_MS,
    });
    assert(response && response.status() === 200, `文档页状态码异常: ${response && response.status()}`);
    await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"] .ProseMirror').first().waitFor({
      state: "visible",
      timeout: UI_TIMEOUT_MS,
    });
    await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS });
    await page.locator('[data-testid="wolai-page-ai-drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
    await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="new-session"]').click({
      timeout: UI_TIMEOUT_MS,
    });
    await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS });
    await page.locator('[data-page-ai-agent-id="chat_only"][data-page-ai-profile-id="shared_doubao_chat"]').click({
      timeout: UI_TIMEOUT_MS,
    });
    await page.waitForFunction(
      () => document.querySelector("[data-page-ai-agent-chip]")?.textContent?.includes("ChatOnly / 豆包"),
      null,
      { timeout: UI_TIMEOUT_MS },
    );

    await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS });
    await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
    const replyState = await page.waitForFunction(
      (expectedMarker) => {
        const assistantText = Array.from(document.querySelectorAll(".wolai-page-ai-message--assistant"))
          .map((node) => node.textContent || "")
          .join("\n");
        if (assistantText.includes(expectedMarker)) return "marker";
        if (assistantText.includes("豆包暂时无法回复")) return "provider_error";
        return "";
      },
      marker,
      { timeout: 180_000 },
    ).then((handle) => handle.jsonValue());
    await page.waitForFunction(
      () => (document.querySelector("[data-page-ai-run-status]")?.textContent || "").includes("完成"),
      null,
      { timeout: 180_000 },
    ).catch(() => {});
    screenshots.afterMessage = await saveScreenshot(page, "after-message");

    assert.strictEqual(runRequests.length, 1, "MNote 本轮应只创建一个 run");
    const run = runRequests[0];
    assert.strictEqual(run.agentId, "chat_only", "应使用 ChatOnly agent");
    assert.strictEqual(run.profileId, "shared_doubao_chat", "应使用豆包 ChatOnly profile");
    assert.strictEqual(run.acpRuntime, "hermes", "豆包 ChatOnly 应走 Hermes/OpenClaw runtime");
    assert(run.sessionId, "run payload 应包含 MNote sessionId");

    const logAfterMessage = await readDoubaoFetchLog(doubaoPage);
    const completionCalls = logAfterMessage.filter((entry) => entry.url.includes("/samantha/chat/completion"));
    assert.strictEqual(completionCalls.length, 0, "豆包 ChatOnly 应走真实 UI 发送，不应再直调 samantha completion");
    assert.notStrictEqual(replyState, "provider_error", "豆包返回限流/风控错误，未产生本轮 marker 回复");

    const visibleAssistantTexts = await page.locator(".wolai-page-ai-message--assistant").allTextContents();
    const markerAssistantCount = visibleAssistantTexts.filter((text) => text.includes(marker)).length;
    assert.strictEqual(markerAssistantCount, 1, "MNote 可见豆包回复应只有一条");

    const sessionId = String(run.sessionId);
    const bindingRows = sqliteJson(
      `SELECT mnote_session_id, remote_conversation_id, status FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
      [],
    );
    assert(bindingRows && bindingRows.length === 1, "SQLite 应保存豆包远端会话绑定");
    assert.strictEqual(bindingRows[0].status, "active", "删除前 binding 应为 active");
    const remoteConversationId = bindingRows[0].remote_conversation_id;
    assert(remoteConversationId, "binding 应包含 remote_conversation_id");

    providerCaptures.afterMessage = await captureDoubaoPage(doubaoPage, {
      name: "doubao-after-message",
      url: doubaoConversationUrl(remoteConversationId),
      remoteConversationId,
    });
    const doubaoMessageStats = await readDoubaoConversationStats(doubaoPage, { marker, prompt });
    screenshots.doubaoAfterMessage = providerCaptures.afterMessage.screenshot;
    assert(
      providerCaptures.afterMessage.pageUrl.includes(remoteConversationId),
      "豆包截图应定位到本轮远端 conversation_id",
    );
    assert(
      providerCaptures.afterMessage.visibleText.includes(marker),
      "豆包本轮远端会话页面应显示 marker",
    );
    assert.strictEqual(
      providerCaptures.afterMessage.conversationRowCount,
      1,
      "豆包删除前左侧历史列表应存在本轮 conversation 行",
    );
    assert.strictEqual(
      doubaoMessageStats.userPromptCount,
      1,
      "豆包网页端本轮用户消息应只有一条",
    );
    assert.strictEqual(
      doubaoMessageStats.userMarkerCount,
      1,
      "豆包网页端不应把豆包回复再次作为用户消息发送",
    );
    assert.strictEqual(
      doubaoMessageStats.assistantMarkerCount,
      1,
      "豆包网页端本轮助手回复应只有一条",
    );

    await installDoubaoFetchProbe(doubaoPage);

    await page.locator('.wolai-page-ai-header-actions [data-page-ai-action="history"]').click({ timeout: UI_TIMEOUT_MS });
    await page.locator('[data-page-ai-panel="history"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
    await page.locator(`[data-page-ai-session-row="${sessionId}"]`).waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
    page.once("dialog", async (dialog) => {
      await dialog.accept();
    });
    await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: UI_TIMEOUT_MS });
    await page.waitForFunction(
      (id) => !document.querySelector(`[data-page-ai-session-row="${CSS.escape(id)}"]`),
      sessionId,
      { timeout: UI_TIMEOUT_MS },
    );
    screenshots.afterDelete = await saveScreenshot(page, "after-delete");

    await page.waitForTimeout(1000);
    const logAfterDelete = await readDoubaoFetchLog(doubaoPage);
    const deleteCalls = logAfterDelete.filter((entry) => entry.url.includes("/im/conversation/batch_del_user_conv"));
    assert.strictEqual(deleteCalls.length, 1, "豆包端本轮只能收到一次会话删除请求");
    assert(deleteCalls[0].body.includes(remoteConversationId), "豆包删除请求应包含绑定的远端 conversation_id");
    assert(deleteResponses.length >= 1, "MNote 应发出历史会话 DELETE 请求");
    assert.strictEqual(deleteResponses.at(-1).status, 200, "MNote 历史会话 DELETE 应成功");
    const deleteBody = JSON.parse(deleteResponses.at(-1).body || "{}");
    assert.strictEqual(
      deleteBody?.result?.providerConversationDelete?.response?.result?.providerDeleteMode,
      "doubao_sidebar_menu",
      "豆包远端删除必须走左侧会话三点菜单 + 确认弹窗路径",
    );

    providerCaptures.afterDelete = await captureDoubaoPage(doubaoPage, {
      name: "doubao-after-delete",
      url: "https://www.doubao.com/chat/",
      remoteConversationId,
    });
    screenshots.doubaoAfterDelete = providerCaptures.afterDelete.screenshot;
    assert(
      !providerCaptures.afterDelete.visibleText.includes(marker),
      "豆包删除后聊天入口不应继续显示本轮 marker",
    );
    assert.strictEqual(
      providerCaptures.afterDelete.conversationRowCount,
      0,
      "豆包删除后左侧历史列表不应继续存在本轮 conversation 行",
    );

    const deletedRows = sqliteJson(
      `SELECT mnote_session_id, remote_conversation_id, status, metadata_json FROM ai_external_conversation_bindings WHERE user_id='${actorId}' AND mnote_session_id='${sessionId}' AND provider='doubao-web' ORDER BY updated_at DESC LIMIT 1;`,
      [],
    );
    assert(deletedRows && deletedRows.length === 1, "删除后 binding 仍应可审计");
    assert.strictEqual(deletedRows[0].status, "remote_deleted", "删除后 binding 应标记 remote_deleted");

    fs.writeFileSync(
      RESULT_PATH,
      `${JSON.stringify({
        ok: true,
        marker,
        sessionId,
        remoteConversationId,
        samanthaCompletionCallCount: completionCalls.length,
        doubaoMessageStats,
        deleteCallCount: deleteCalls.length,
        deleteResponse: deleteResponses.at(-1),
        bindingBefore: bindingRows[0],
        bindingAfter: deletedRows[0],
        screenshots,
        providerCaptures,
      }, null, 2)}\n`,
      "utf8",
    );
  } catch (error) {
    caughtError = error;
    screenshots.failure = await saveScreenshot(page, "failure").catch(() => "");
    providerCaptures.failure = await captureDoubaoPage(doubaoPage, {
      name: "doubao-failure",
      url: "",
    }).catch((captureError) => ({
      screenshot: "",
      requestedUrl: "",
      pageUrl: "",
      title: "",
      navigationError: captureError instanceof Error ? captureError.message : String(captureError),
      visibleText: "",
    }));
    if (providerCaptures.failure.screenshot) {
      screenshots.doubaoFailure = providerCaptures.failure.screenshot;
    }
    fs.writeFileSync(
      RESULT_PATH,
      `${JSON.stringify({
        ok: false,
        marker,
        error: error instanceof Error ? error.stack || error.message : String(error),
        runRequests,
        deleteResponses,
        doubaoFetchLog: await readDoubaoFetchLog(doubaoPage).catch(() => []),
        screenshots,
        providerCaptures,
      }, null, 2)}\n`,
      "utf8",
    );
  } finally {
    await browser.close().catch(() => {});
    await doubaoBrowser.close().catch(() => {});
  }

  if (caughtError) throw caughtError;
  console.log(JSON.stringify(JSON.parse(fs.readFileSync(RESULT_PATH, "utf8")), null, 2));
}

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