#!/usr/bin/env bun
/**
 * 桌面客户端端到端闭环(可在源码仓库或离线包里跑):
 *   bun run scripts/gui-e2e.ts                                  # 用 src-tauri/target/release/deepowl-gui.exe
 *   bun run scripts/gui-e2e.ts --exe D:\pkg\DeepOwl.exe --shots .verify/gui/bundle   # 验证离线包形态
 *
 * 覆盖:设置端点(本机 OpenAI 兼容端点 + 连通性)→ 建索引 → 中文短词检索 → 上传 docx 生成方案 →
 * 勾选索引文档生成方案 → 执行(真实 Edge + 演示应用,断言 3/3)→ 夜间流水线(执行 + 报告 + 推送)→ 报告渲染。
 * 依赖:本机 Edge/Chrome;本机 LLM 端点(默认 127.0.0.1:8098,可用 DEEPOWL_E2E_LLM 覆盖)。
 * 副作用:会在仓库工作区生成/更新 plans/、artifacts/、reports/(均为正常运行产物;.verify/ 存放截图与临时设置)。
 *
 * 实现要点:CDP(WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS)+ 独立 WEBVIEW2_USER_DATA_FOLDER
 * (默认数据目录在被强杀后会残留坏状态,导致下次启动 panic);断言一律通过 CDP 读真实 DOM。
 */
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";

const REPO = path.resolve(import.meta.dir, "..");
const argOf = (name: string, fallback: string) => {
  const at = process.argv.indexOf(name);
  return at >= 0 && process.argv[at + 1] ? process.argv[at + 1]! : fallback;
};
const EXE = argOf("--exe", path.join(REPO, "src-tauri", "target", "release", "deepowl-gui.exe"));
const CDP_PORT = Number(argOf("--port", "9341"));
const SHOTS = argOf("--shots", path.join(REPO, ".verify", "gui", "e2e"));
const SETTINGS = path.join(REPO, ".verify", "gui", "e2e-settings.json");
const LLM_BASE = process.env.DEEPOWL_E2E_LLM ?? "http://127.0.0.1:8098/v1";
const UPLOAD_DOC = path.join(REPO, "examples", "requirements", "支付与退款需求.docx");
const GREEN_PLAN = path.join(REPO, "examples", "test-plan.example.md");

const child = spawn(EXE, [], {
  cwd: REPO,
  env: {
    ...process.env,
    WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: `--remote-debugging-port=${CDP_PORT}`,
    WEBVIEW2_USER_DATA_FOLDER: `${REPO}/.verify/gui/wv-data3`,
    DEEPOWL_USER_SETTINGS: SETTINGS,
  },
  stdio: ["ignore", "pipe", "pipe"],
  windowsHide: false,
});
const guiLog: string[] = [];
child.stdout.on("data", (c: Buffer) => guiLog.push(c.toString().trim()));
child.stderr.on("data", (c: Buffer) => guiLog.push(`[err] ${c.toString().trim()}`));

function delay(ms: number): Promise<void> {
  const { promise, resolve } = Promise.withResolvers<void>();
  setTimeout(resolve, ms);
  return promise;
}

/** 演示应用就绪:直接探端口,避免按钮文案与实际状态不一致导致误判。 */
async function waitDemoReady(timeoutMs = 30_000): Promise<boolean> {
  const started = Date.now();
  while (Date.now() - started < timeoutMs) {
    try {
      const res = await fetch("http://127.0.0.1:4173/", { signal: AbortSignal.timeout(2000) });
      if (res.ok) return true;
    } catch {
      /* 还没起来 */
    }
    await delay(500);
  }
  return false;
}

function field(value: unknown, key: string): unknown {
  return value && typeof value === "object" && key in value ? (value as Record<string, unknown>)[key] : undefined;
}

async function findPage(): Promise<string> {
  for (let attempt = 0; attempt < 60; attempt += 1) {
    try {
      const pages: unknown = await (await fetch(`http://127.0.0.1:${CDP_PORT}/json/list`)).json();
      if (Array.isArray(pages)) {
        for (const page of pages) {
          const url = field(page, "webSocketDebuggerUrl");
          if (field(page, "type") === "page" && typeof url === "string") return url;
        }
      }
    } catch {
      /* 未就绪 */
    }
    await delay(500);
  }
  throw new Error("未找到 WebView2 页面");
}

class Cdp {
  private static nextId = 1;
  private pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>();

  constructor(private readonly ws: WebSocket) {
    this.ws.addEventListener("message", (event) => {
      const message: unknown = JSON.parse(String(event.data));
      const id = field(message, "id");
      if (typeof id !== "number") return;
      const waiter = this.pending.get(id);
      if (!waiter) return;
      this.pending.delete(id);
      const error = field(message, "error");
      if (error) waiter.reject(new Error(JSON.stringify(error).slice(0, 300)));
      else waiter.resolve(field(message, "result"));
    });
  }

  send(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
    const id = Cdp.nextId++;
    const { promise, resolve, reject } = Promise.withResolvers<unknown>();
    this.pending.set(id, { resolve, reject });
    this.ws.send(JSON.stringify({ id, method, params }));
    return promise;
  }

  async eval(expression: string): Promise<unknown> {
    const res = await this.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
    if (field(res, "exceptionDetails")) throw new Error(`JS 异常:${JSON.stringify(field(res, "exceptionDetails")).slice(0, 200)}`);
    return field(field(res, "result"), "value");
  }

  async text(expression: string): Promise<string> {
    return String((await this.eval(expression)) ?? "");
  }

  async shot(name: string): Promise<void> {
    const res = await this.send("Page.captureScreenshot", { format: "png" });
    const data = field(res, "data");
    if (typeof data === "string") await writeFile(`${SHOTS}/${name}.png`, Buffer.from(data, "base64"));
  }

  async waitFor(expression: string, label: string, timeoutMs = 420_000): Promise<void> {
    const started = Date.now();
    while (Date.now() - started < timeoutMs) {
      if ((await this.eval(expression)) === true) return;
      await delay(500);
    }
    throw new Error(`等待超时:${label}`);
  }
}

await mkdir(SHOTS, { recursive: true });
const socket = new WebSocket(await findPage());
{
  const { promise, resolve, reject } = Promise.withResolvers<void>();
  socket.addEventListener("open", () => resolve());
  socket.addEventListener("error", () => reject(new Error("WebSocket 连接失败")));
  await promise;
}
const cdp = new Cdp(socket);
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");

const results: { step: string; ok: boolean; detail: string }[] = [];
const record = (step: string, ok: boolean, detail: string) => {
  results.push({ step, ok, detail });
  console.log(`${ok ? "✓" : "✖"} ${step}:${detail}`);
};

try {
  await cdp.waitFor("!!(document.getElementById('env-mode') && document.getElementById('env-mode').textContent !== '检测中…')", "界面初始化", 90_000);
  record("界面初始化", true, await cdp.text("document.getElementById('env-mode').textContent"));

  /* ① 设置 */
  await cdp.eval(`document.querySelector('.tab[data-tab="settings"]').click()`);
  await cdp.waitFor("document.getElementById('about-box').textContent.includes('开发者') === true", "关于卡片");
  const about = await cdp.text("document.getElementById('about-box').textContent");
  record("关于(开发者/命名空间)", about.includes("CodeLife leon") && about.includes("top.softwarescience"), about.replace(/\n+/g, " / ").slice(40, 150));
  await cdp.eval(`(() => {
    document.getElementById('llm-base-url').value = '${LLM_BASE}';
    document.getElementById('llm-model').value = 'local';
    document.getElementById('btn-llm-save').click();
  })()`);
  await cdp.waitFor("document.getElementById('llm-status').textContent.includes('已保存') === true", "保存端点");
  await cdp.eval("document.getElementById('btn-llm-test').click()");
  await cdp.waitFor("document.getElementById('llm-status').textContent.includes('端点可用') || document.getElementById('llm-status').textContent.includes('端点不可用')", "测试连接");
  record("端点配置 + 连通性", (await cdp.text("document.getElementById('llm-status').textContent")).includes("端点可用"), await cdp.text("document.getElementById('llm-status').textContent"));
  await cdp.shot("01-settings");

  /* ② 索引 */
  await cdp.eval(`document.querySelector('.tab[data-tab="docs"]').click()`);
  await cdp.eval("document.getElementById('btn-index').click()");
  await cdp.waitFor("document.getElementById('index-stats').textContent.includes('扫描') === true", "建立索引");
  record("建立索引", true, await cdp.text("document.getElementById('index-stats').innerText"));
  await cdp.eval(`(() => { document.getElementById('search-input').value = '退款 幂等'; document.getElementById('btn-search').click(); })()`);
  await cdp.waitFor("document.querySelectorAll('#search-results li').length > 0", "中文短词检索");
  record("中文短词检索", true, `命中 ${await cdp.text("document.querySelectorAll('#search-results li').length")} 条`);
  await cdp.shot("02-docs");

  /* ③ 上传 docx 生成方案 */
  await cdp.eval(`document.querySelector('.tab[data-tab="plan"]').click()`);
  await cdp.waitFor("document.querySelectorAll('#plan-docs li').length > 0", "文档清单");
  await cdp.eval(`(() => {
    const input = document.getElementById('plan-doc-paths');
    input.value = ${JSON.stringify(UPLOAD_DOC)};
    input.dispatchEvent(new Event('input'));
    document.getElementById('plan-title').value = '支付与退款需求方案(上传)';
    document.getElementById('btn-plan').click();
  })()`);
  await cdp.waitFor("Array.from(document.querySelectorAll('#plan-list li')).some(li => li.textContent.includes('支付与退款需求方案')) === true", "上传方案落盘");
  await cdp.eval("Array.from(document.querySelectorAll('#plan-list li')).find(li => li.textContent.includes('支付与退款需求方案')).click()");
  await cdp.waitFor("document.getElementById('plan-body').textContent.includes('deepowl-case') === true", "上传方案预览");
  const uploadedCases = (await cdp.text("document.getElementById('plan-body').textContent")).match(/```deepowl-case/g)?.length ?? 0;
  record("上传 docx 生成方案", uploadedCases > 0, `产出 ${uploadedCases} 条用例`);
  await cdp.shot("03-plan-uploaded");

  /* ④ 勾选索引文档生成方案 */
  await cdp.eval(`(() => {
    for (const box of document.querySelectorAll('#plan-docs input[type="checkbox"]')) {
      box.checked = box.value.includes('login.md');
      box.dispatchEvent(new Event('change'));
    }
    const input = document.getElementById('plan-doc-paths');
    input.value = '';
    input.dispatchEvent(new Event('input'));
    document.getElementById('plan-title').value = '登录闭环方案';
    document.getElementById('btn-plan').click();
  })()`);
  await cdp.waitFor("Array.from(document.querySelectorAll('#plan-list li')).some(li => li.textContent.includes('登录闭环方案')) === true", "勾选方案落盘");
  await cdp.eval("Array.from(document.querySelectorAll('#plan-list li')).find(li => li.textContent.includes('登录闭环方案')).click()");
  await cdp.waitFor("document.getElementById('plan-body').textContent.includes('deepowl-case') === true", "勾选方案预览");
  const pickedCases = (await cdp.text("document.getElementById('plan-body').textContent")).match(/```deepowl-case/g)?.length ?? 0;
  record("勾选索引文档生成方案", pickedCases > 0, `产出 ${pickedCases} 条用例`);
  await cdp.shot("04-plan-picked");

  /* ⑤ 执行(用示例方案跑绿:真实 Edge + 演示应用) */
  await cdp.eval(`document.querySelector('.tab[data-tab="run"]').click()`);
  if (!(await cdp.eval("document.getElementById('btn-demo').textContent.includes('停止')"))) {
    await cdp.eval("document.getElementById('btn-demo').click()");
    await cdp.waitFor("document.getElementById('btn-demo').textContent.includes('停止') === true", "演示应用启动");
  }
  if (!(await waitDemoReady())) throw new Error("演示应用未就绪:127.0.0.1:4173 无响应(GUI 的「启动演示应用」可能失败)");
  await cdp.eval(`(() => {
    document.getElementById('run-plan').value = ${JSON.stringify(GREEN_PLAN)};
    document.getElementById('run-case').value = '';
    document.getElementById('btn-run').click();
  })()`);
  await cdp.waitFor("document.getElementById('run-status').textContent.includes('已完成') || document.getElementById('run-status').textContent.includes('失败')", "执行结束", 600_000);
  await cdp.waitFor("document.querySelectorAll('#run-results tr').length > 0", "结果表渲染", 60_000);
  const runStatus = await cdp.text("document.getElementById('run-status').textContent");
  const runSummary = await cdp.text("document.getElementById('run-summary').textContent");
  record("执行(真实 Edge + 演示应用)", runStatus.includes("已完成") && /通过 3\/3/.test(runSummary), `${runSummary},结果行 ${await cdp.text("document.querySelectorAll('#run-results tr').length")}`);
  await cdp.shot("05-run");

  /* ⑥ 夜间流水线(同样限制用例子集,避免全量 23 条超出验证预算) */
  await cdp.eval(`(() => {
    document.getElementById('run-case').value = 'TC-LOGIN-001,TC-LOGIN-002';
    document.getElementById('btn-night').click();
  })()`);
  await cdp.waitFor("document.getElementById('run-status').textContent.includes('夜间流水线完成') || document.getElementById('run-status').textContent.includes('失败')", "夜间流水线", 600_000);
  const nightLog = await cdp.text("document.getElementById('run-log').textContent");
  record("夜间流水线(执行+报告+推送)", /夜间流水线完成/.test(nightLog) && /推送 file.*成功/.test(nightLog), /夜间流水线完成[\s\S]{0,120}/.exec(nightLog)?.[0].replace(/\n/g, " / ") ?? "未完成");
  await cdp.shot("06-night");

  /* ⑦ 报告 */
  await cdp.eval(`document.querySelector('.tab[data-tab="report"]').click()`);
  await cdp.eval("document.getElementById('btn-report-list').click()");
  await cdp.waitFor("document.querySelectorAll('#report-list li').length > 0", "报告列表");
  await cdp.eval("document.querySelector('#report-list li').click()");
  await cdp.waitFor("document.getElementById('report-body').innerHTML.length > 300", "报告渲染", 60_000);
  const reportText = await cdp.text("document.getElementById('report-body').innerText");
  record("报告渲染", reportText.includes("执行摘要"), reportText.split("\n").slice(0, 2).join(" / "));
  await cdp.shot("07-report");
} finally {
  const failed = results.filter((item) => !item.ok);
  console.log(`\n=== 闭环结果:${results.length - failed.length}/${results.length} 通过 ===`);
  for (const item of results) console.log(`${item.ok ? "✓" : "✖"} ${item.step}:${item.detail}`);
  if (failed.length) {
    console.log("GUI 日志:", guiLog.slice(0, 6).join(" | "));
    const tail = await cdp.eval("document.getElementById('run-log') ? document.getElementById('run-log').textContent.split('\\n').slice(-6).join(' / ') : ''").catch(() => "");
    console.log("执行日志尾部:", String(tail).slice(0, 400));
  }
  socket.close();
  child.kill();
  await delay(1200);
  console.log("已停止验证实例 pid =", child.pid, "| 截图:", SHOTS);
  // 子进程管道可能让事件循环继续存活:显式按结果退出
  process.exit(failed.length ? 1 : 0);
}