/**
 * Auto-compaction triggered at the **end** of a round: item order is
 * assistant_text -> compaction -> task_stats, with the compaction banner sandwiched between
 * the reply and its stats line.
 *
 * This ordering used to break the "reply + stats line" pairing, leaving the stats line
 * orphaned — and since the whole line is transparent by default, being orphaned meant it was
 * neither visible nor hoverable (the element's own `group` class has no effect on itself:
 * group-hover is a descendant selector). Now the entire round's AI-side content, stats line
 * included, is wrapped in the same group, so hovering any content in the round reveals it.
 *
 * Trigger mechanism: the mock's usage grows with context length, pinning the threshold between
 * "below the first request" and "above the second request (which carries the tool result)", so
 * compaction only happens once, at the end of the round.
 */
import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";

const BASE = process.env.BASE_URL;
const MOCK = process.env.MOCK_URL;
const U = "compactuser";
const P = "password123";
/** Context window: the engine takes 75% as the compaction threshold (240 x 0.75 = 180), landing between the two requests' usage. */
const CONTEXT_WINDOW = 240;

test("compaction mid-turn: the reply's stats line is still reachable by hovering the reply", async ({
  page,
}) => {
  await provisionAndLogin(page.request, U, P);
  const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
  const projectId = projects.projects[0].projectId;
  await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
    data: {
      defaultModel: { provider: "custom", modelId: "claude-4-8" },
      models: [
        {
          provider: "custom",
          modelId: "claude-4-8",
          apiKey: "sk-mock",
          baseUrl: MOCK,
          contextWindow: CONTEXT_WINDOW,
          pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
        },
      ],
    },
  });
  const sess = await (
    await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
      data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
    })
  ).json();

  await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
  const ta = page.getByPlaceholder(/输入消息/);
  await ta.waitFor();
  await ta.fill("Help me set up @theme");
  await page.getByRole("button", { name: "发送" }).click();

  const reply = page.getByText("Command finished; the result looks as expected.").first();
  await expect(reply).toBeVisible();
  const banner = page.getByText(/\[压缩\]/).first();
  await expect(banner).toBeVisible();

  // The compaction banner doesn't show Token counts: compaction at round end isn't attributed
  // to this round (its usage shows up in the Session total and the Trace page's compaction-round
  // card instead) — the banner only states "compaction happened, succeeded or not."
  await expect(page.getByText(/\[压缩\].*tokens/)).toHaveCount(0);

  // Order: reply -> **this round's stats line** -> compaction banner. The stats line is about
  // this round of conversation; compaction is housekeeping outside this round, listed after this
  // round's tally. (It used to be the other way around: the banner sat between the reply and the
  // stats line, making the stats line read like "compaction's stats," and this round's elapsed
  // time would fold in the entire compaction request — compaction is itself a full LLM request.)
  const copyBtn = page.getByRole("button", { name: "复制回复" }).first();
  const statsLine = copyBtn.locator("xpath=..");
  const yOf = async (l) => (await l.boundingBox()).y;
  expect(await yOf(statsLine)).toBeGreaterThan(await yOf(reply));
  expect(await yOf(banner)).toBeGreaterThan(await yOf(statsLine));

  // Transparent by default but space-reserved; hovering **the reply itself** reveals it
  // (it used to be orphaned -> never revealable).
  await page.mouse.move(0, 0);
  await expect(statsLine).toHaveCSS("opacity", "0");
  await reply.hover();
  await expect(statsLine).toHaveCSS("opacity", "1");
  await expect(statsLine.locator("text=/\\d{1,2}:\\d{2}/")).toBeVisible();

  // Compaction succeeded and no ordinary request has reported usage since, so the context ring must
  // read UNKNOWN (`—`), not 0. Zero would claim the context is empty — but the summary itself costs
  // tokens; we simply have not measured the new size yet.
  await expect(page.getByText(`—/${CONTEXT_WINDOW}`)).toBeVisible();
  await expect(page.getByText(`0/${CONTEXT_WINDOW}`)).toHaveCount(0);

  // —— Trace page ——
  // Compaction is its own round: the user round's elapsed time and TPS don't include compaction
  // (matching the chat page's convention), and the compaction round has **its own TPS** (how
  // fast the summary was generated), not a "—".
  await page.goto(`${BASE}/traces`);
  const main = page.locator("main");
  const node = main.getByText(/Configure|新对话/).first();
  await expect(node).toBeVisible();
  await node.click();
  await expect(main.getByText("第 1 轮")).toBeVisible();
  await expect(main.getByText("第 2 轮")).toBeVisible();
  const roundRows = main.locator("button").filter({ hasText: /第 \d 轮/ });
  const compactionRow = roundRows.filter({ hasText: "第 2 轮" }).first();
  await expect(compactionRow).toContainText("tok/s"); // the compaction round has its own TPS
  await expect(compactionRow).not.toContainText("—");

  // Global elapsed time = **the sum of each round's elapsed time (including the compaction
  // round)**, matching the same scope as the per-round display below — adding up each round
  // card's elapsed time must equal the total. But it's not "last message - first message" (that
  // would include the gap while the user was thinking/away between rounds, which isn't the
  // Agent's working time). Assert this property directly against the API — the elapsed time
  // shown in the UI is rounded by humanizeDuration, so parsing it back out of the text would be
  // both fragile and inaccurate.
  const traces = await (
    await page.request.get(`${BASE}/api/projects/${projectId}/agents/default_agent/traces`)
  ).json();
  const file = traces.dates[0].sessions[0].files[0];
  const analysis = await (
    await page.request.get(
      `${BASE}/api/projects/${projectId}/agents/default_agent/traces/${sess.session.sessionId}/${file.index}/analysis`,
    )
  ).json();
  expect(analysis.tasks).toHaveLength(2);

  // The compaction round is flagged as "compaction" and counts toward the total elapsed time
  // just like a user round; each has an elapsed time greater than 0.
  const userTasks = analysis.tasks.filter((t) => t.compaction !== true);
  const compactionTasks = analysis.tasks.filter((t) => t.compaction === true);
  expect(userTasks).toHaveLength(1);
  expect(compactionTasks).toHaveLength(1);
  const allSum = analysis.tasks.reduce(
    (acc, t) => acc + (Date.parse(t.endTs) - Date.parse(t.startTs)),
    0,
  );
  expect(analysis.elapsedMs).toBe(allSum);
  expect(
    Date.parse(compactionTasks[0].endTs) - Date.parse(compactionTasks[0].startTs),
  ).toBeGreaterThan(0);
});

/**
 * Manual `/compact` between two rounds: reloading the page must **not** rewrite the previous
 * round's tally.
 *
 * The server's startCompact requires the Session to be idle, so manual compaction always falls
 * outside a round. In the live stream, the previous round already closed at idle, so compaction's
 * usage and duration never fold into it; but history rebuild has no idle signal, so that round
 * still looks open — compaction's Token, cost, and elapsed time would get folded wholesale into
 * the previous round. The symptom: the same reply's stats look fine at first, then after a
 * reload they flip from "260 tokens · $0.0012 · 401ms" to "510 · $0.0020 · 1.7s".
 *
 * The unit test (stream-model) locks down the message sequence, but it can't lock down whether
 * a real `/compact` actually emits compaction_begin.reason === manual — that's exactly what the
 * frontend relies on to decide "compaction is outside the round," so this is covered end-to-end
 * here. Compaction is deliberately delayed by 3 seconds (the user reads the reply, then remembers
 * to compact): if that gap were counted, the elapsed time would jump from hundreds of
 * milliseconds to over 3 seconds.
 */
test("manual /compact between turns: reloading must not fold the compaction into the previous turn", async ({
  page,
}) => {
  const U2 = "manualcompact";
  await provisionAndLogin(page.request, U2, P);
  const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
  const projectId = projects.projects[0].projectId;
  await page.request.put(`${BASE}/api/projects/${projectId}/models`, {
    data: {
      defaultModel: { provider: "custom", modelId: "claude-4-8" },
      models: [
        {
          provider: "custom",
          modelId: "claude-4-8",
          apiKey: "sk-mock",
          baseUrl: MOCK,
          // Window large enough that compaction only ever triggers manually via /compact.
          contextWindow: 200000,
          pricing: { cacheRead: 1, cacheWrite: 5, output: 10 },
        },
      ],
    },
  });
  const sess = await (
    await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
      data: { provider: "custom", modelId: "claude-4-8", approvalMode: "allow-all" },
    })
  ).json();

  await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
  const ta = page.getByPlaceholder(/输入消息/);
  await ta.waitFor();
  await ta.fill("Help me set up @theme");
  await page.getByRole("button", { name: "发送" }).click();
  await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();

  // The whole stats line is transparent by default, but innerText still reads fine (no need to hover).
  const statsText = async () =>
    (await page.getByRole("button", { name: "复制回复" }).first().locator("xpath=..").innerText())
      .replace(/\n/g, " ")
      .trim();
  const costOf = (t) => t.match(/\$[\d.]+/)?.[0] ?? null;
  const elapsedMsOf = (t) => {
    const hit = [...t.matchAll(/(\d+(?:\.\d+)?)(ms|s)(?![\w/])/g)].at(-1);
    return hit ? Number(hit[1]) * (hit[2] === "s" ? 1000 : 1) : null;
  };

  const live = await statsText();
  expect(costOf(live), `stats line should include a cost: ${live}`).not.toBeNull();
  expect(elapsedMsOf(live)).toBeLessThan(1500);

  // The user reads the reply, then 3 seconds later remembers to compact.
  await page.waitForTimeout(3000);
  await ta.fill("/compact");
  await ta.press("Enter");
  await expect(page.getByText(/\[压缩\]/)).toBeVisible();
  await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
  expect(await statsText(), "compaction must not touch the already-closed prior turn").toBe(live);

  await page.reload();
  await ta.waitFor();
  await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();
  const rebuilt = await statsText();
  // Cost is derived from Token counts: if compaction's usage were folded in, cost would double out of nowhere.
  expect(costOf(rebuilt), `cost must survive reload (${live} -> ${rebuilt})`).toBe(costOf(live));
  // Elapsed time should include neither the 3-second thinking gap nor compaction itself.
  expect(elapsedMsOf(rebuilt), `elapsed must not eat compaction (${rebuilt})`).toBeLessThan(1500);
});
