/**
 * End-to-end test for the draft-state new-conversation flow:
 * - /chat/new lets you pick Model / approval mode up front; the Session is only created when
 *   the first message is sent, and all four selections land faithfully in its meta;
 * - the draft auto-caches (body persisted via debounce): after a page reload, both the body and
 *   the selections are restored, and the cache clears once sending succeeds;
 * - the sidebar defaults to grouping by Workspace: temporary workspaces merge into one
 *   "临时工作区" group, a named directory groups under its basename, and that group header's
 *   "+" pre-fills the draft's Workspace (via router state, applied once per navigation — a
 *   manual change made afterwards survives a reload instead of being re-overridden);
 * - the group header's hover pin toggle lifts a group above the unpinned ones in its mode and
 *   persists per Project (order re-checked after a reload);
 * - after switching the sidebar to agent mode (toggle persisted in localStorage), the agent
 *   group header's "+" creates a draft scoped to that group's Agent (explicitly set via router
 *   state, overriding the cache);
 * - both switch commands are **staged**: `/model` and `/agent` pin a chip and send nothing, the
 *   chip is cached with the body text (it survives a reload), and pressing Enter afterwards is
 *   what actually forks the conversation onto the picked model / hands it to the picked Agent —
 *   carrying the text typed after the pick into the new conversation.
 */
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
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 = "draftuser";
const P = "password123";

test("draft: pick model/approval -> reload restores them -> send creates the session and clears the cache -> sidebar + scopes the Agent", async ({
  page,
}) => {
  // Draft keys are isolated by user x Project/Session (#68); building the key needs userId (i.e. the username).
  const userId = (await provisionAndLogin(page.request, U, P)).userId;

  const projects = await (await page.request.get(`${BASE}/api/projects`)).json();
  const projectId = projects.projects[0].projectId;

  // Two models: claude-4-8 as default, plus claude-4-8-mini for the draft to switch to (both point at the mock).
  const put = 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: 200000,
        },
        {
          provider: "custom",
          modelId: "claude-4-8-mini",
          apiKey: "sk-mock",
          baseUrl: MOCK,
          contextWindow: 100000,
        },
      ],
    },
  });
  expect(put.ok(), "put models").toBeTruthy();

  // The only builtin Agent is default_agent, so the /agent handoff and sidebar group-header "+" targets use a custom-created Agent.
  const created = await page.request.post(`${BASE}/api/projects/${projectId}/agents`, {
    data: { agentId: "agent_helper", name: "Helper Agent" },
  });
  expect(created.ok(), "create helper agent").toBeTruthy();

  // No Session exists yet: entering the site lands on the draft page (the brand heading marks the draft page).
  await page.goto(`${BASE}/chat`);
  await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();

  const ta = page.getByPlaceholder(/输入消息/);

  // `/agent` is a SESSION command: a draft has no conversation to hand over, and its Agent is
  // chosen by the draft page's own selector — so the slash menu must not offer it here (the
  // staged-handoff flow itself is covered in the session section below). The menu does open on
  // the same prefix, matching the installed `/agent-*` skills — which is what makes the absent
  // command row a real assertion rather than a menu that simply never appeared.
  await ta.fill("/agent");
  await expect(page.getByRole("button", { name: /^\/agent-creation/ })).toBeVisible();
  await expect(
    page.getByRole("button", { name: "/agent 交给其他 Agent，发送时开启新会话" }),
  ).toHaveCount(0);
  await ta.fill("Draft body must not be lost");

  // Switch the model: the selector sits to the left of the send button, opens downward, with a quick-search field at the top.
  await page.getByRole("button", { name: "选择模型" }).click();
  await page.getByPlaceholder(/搜索模型/).fill("mini");
  await page.getByRole("button", { name: /claude-4-8-mini/ }).click();

  // Switch the approval mode to read-only (the trigger button shows the Chinese description).
  await page.getByRole("button", { name: "审批模式" }).click();
  await page.getByRole("button", { name: /放行只读/ }).click();
  await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");

  // Conversation-time thinking level (backed by the Agent settings): the picker shows the
  // seeded default (medium, short name 中); the menu carries a title bar and the short-name
  // rows 低/中/高/极高 only — no descriptions, no default row, and no 无 (many models cannot
  // disable thinking); picking 高 writes straight through to the Agent config, so the session
  // created on send runs with it and it becomes the Agent's new default.
  const thinkingBtn = page.getByRole("button", { name: "思考等级" });
  await expect(thinkingBtn).toContainText("中");
  await thinkingBtn.click();
  await expect(page.getByText("思考等级", { exact: true })).toBeVisible(); // menu title bar
  await expect(page.getByRole("button", { name: "低", exact: true })).toBeVisible();
  await expect(page.getByRole("button", { name: "无", exact: true })).toHaveCount(0);
  await page.getByRole("button", { name: "高", exact: true }).click();
  await expect(thinkingBtn).toContainText("高");
  await expect
    .poll(async () => {
      const cfg = await (
        await page.request.get(`${BASE}/api/projects/${projectId}/agents/default_agent/config`)
      ).json();
      return cfg.config.model?.thinkingLevel;
    })
    .toBe("high");

  // Reload only after the body is persisted via debounce: both the body and the two selections should restore from the cache.
  const draftKey = `penguin.chatDraft.${userId}.${projectId}`;
  await expect
    .poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey))
    .toContain("Draft body must not be lost");

  await page.reload();
  await expect(ta).toHaveValue("Draft body must not be lost");
  // After restoring, the cursor lands at the end of the draft (focus defaults to the start, so it must be explicitly moved to the end to keep typing).
  await expect
    .poll(() =>
      page.evaluate(() => {
        const el = document.querySelector("textarea");
        return el ? el.selectionStart === el.value.length && el.value.length > 0 : false;
      }),
    )
    .toBe(true);
  await expect(page.getByRole("button", { name: "选择模型" })).toContainText("claude-4-8-mini");
  await expect(page.getByRole("button", { name: "审批模式" })).toContainText("放行只读");
  // The thinking level is NOT draft state: it restores from the Agent config (written through above), not the cache.
  await expect(page.getByRole("button", { name: "思考等级" })).toContainText("高");
  // Send: the Session is only created now, and the selections land faithfully in its meta.
  await page.getByRole("button", { name: "发送" }).click();
  await page.waitForURL(/\/chat\/session-/);
  const firstSessionId = page.url().split("/chat/")[1];
  const first = await (await page.request.get(`${BASE}/api/sessions/${firstSessionId}`)).json();
  expect(first.session.agentId).toBe("default_agent");
  expect(first.session.modelId).toBe("claude-4-8-mini");
  expect(first.session.provider).toBe("custom");
  expect(first.session.approvalMode).toBe("read-only");

  // session_meta holds per-session invariants only: the thinking level became a per-turn
  // Task parameter and is no longer recorded in the trace meta. The session composer shows
  // the editable per-turn picker instead of the old read-only tag: while the user hasn't
  // picked, it displays the Agent config's level (auto-follow — "high" was written through
  // above) and sends omit the level; a pick sticks and rides on every subsequent send.
  const replay = await (
    await page.request.get(`${BASE}/api/sessions/${firstSessionId}/messages`)
  ).json();
  const meta = replay.messages.find((m) => m.type === "session_meta");
  expect(meta?.payload?.thinking_level).toBeUndefined();
  await expect(page.getByTitle("思考等级：高")).toBeVisible();

  // On a successful send the cache clears — except the model selection, which carries over as
  // the next conversation's default (switch-becomes-default, like the thinking level above).
  await expect
    .poll(() => page.evaluate((k) => localStorage.getItem(k), draftKey))
    .toContain("claude-4-8-mini");
  expect(await page.evaluate((k) => localStorage.getItem(k), draftKey)).not.toContain(
    "Draft body must not be lost",
  );

  // —— Default grouping: the sidebar groups Sessions by Workspace — the session just created
  // used an auto-created temporary workspace, so it lands in the merged "临时工作区" group.
  // Scoped to the sidebar (<aside>): the draft page's Workspace pill reads 临时工作区 too
  // (S.chat.workspaceAuto === S.chat.tempWorkspaces), so a page-wide text lookup would be
  // ambiguous whenever both are on screen. ——
  await expect(page.locator("aside").getByText("临时工作区", { exact: true })).toBeVisible();

  // A session in a named Workspace groups under that directory's basename, and its group
  // header's "+" pre-fills the draft's Workspace selection with the group's path.
  const namedWs = mkdtempSync(join(tmpdir(), "penguin-e2e-ws-"));
  const namedRes = await page.request.post(
    `${BASE}/api/projects/${projectId}/agents/default_agent/sessions`,
    { data: { workspace: namedWs } },
  );
  expect(namedRes.ok(), "create session in named workspace").toBeTruthy();
  await page.reload();
  const wsLabel = basename(namedWs);
  const wsHeader = page
    .getByText(wsLabel, { exact: true })
    .locator("xpath=ancestor::div[contains(@class,'items-center')][1]");
  await wsHeader.getByRole("button", { name: "在此工作区新建对话" }).click();
  await expect(page.getByRole("heading", { name: "PenguinHarness" })).toBeVisible();
  await expect(page.getByLabel("Workspace")).toContainText(wsLabel);

  // Regression (review): the route-state prefill applies once per navigation only — after the
  // user picks a different directory and reloads, the restored cached choice must win; the
  // prefill must NOT re-apply (location.state survives a reload inside history.state, so the
  // consumed marker lives in sessionStorage rather than a ref). Browse one level up and select
  // it; the path row mirrors the loaded directory, which orders the two clicks deterministically.
  await page.getByRole("button", { name: "Workspace", exact: true }).click();
  // Match by trailing basename: the server realpaths the browsed directory, so the prefix may differ from the raw mkdtemp path.
  await expect(page.getByRole("textbox", { name: "Workspace" })).toHaveValue(
    new RegExp(`${wsLabel}$`),
  );
  // Regression (workspace picker race): while a /dirs request is in flight the picker's rows
  // are disabled, so a rapid double-click on "parent dir" must issue exactly ONE request and
  // ascend exactly one level — previously both clicks fired an un-sequenced load and could
  // relocate the browsing position. The response is gated on an explicit release (not a
  // timeout) so the second click deterministically lands inside the loading window.
  let releaseDirs;
  const dirsGate = new Promise((resolve) => {
    releaseDirs = resolve;
  });
  let dirsRequests = 0;
  const dirsRoute = (url) => url.pathname.endsWith("/dirs");
  const gateDirs = async (route) => {
    dirsRequests += 1;
    await dirsGate;
    await route.continue();
  };
  await page.route(dirsRoute, gateDirs);
  const upRow = page.getByRole("button", { name: "上级目录" });
  await upRow.click();
  // force: the row is disabled while loading, and a plain click would stall on Playwright's
  // actionability wait instead of exercising the double-click; the disabled button swallows it.
  await upRow.click({ force: true });
  releaseDirs();
  const parentLabel = basename(dirname(namedWs));
  await expect(page.getByRole("textbox", { name: "Workspace" })).toHaveValue(
    new RegExp(`${parentLabel}$`),
  );
  await expect(page.getByRole("textbox", { name: "Workspace" })).not.toHaveValue(
    new RegExp(`${wsLabel}$`),
  );
  expect(dirsRequests, "double-click while loading fires a single /dirs request").toBe(1);
  await page.unroute(dirsRoute, gateDirs);
  await page.getByRole("button", { name: "使用此目录" }).click();
  await expect(page.getByLabel("Workspace")).toContainText(parentLabel);
  await page.reload();
  await expect(page.getByLabel("Workspace")).toContainText(parentLabel);
  await expect(page.getByLabel("Workspace")).not.toContainText(wsLabel);

  // —— Pinning: the header's hover pin toggle lifts a group above the others in its mode and
  // persists per Project (localStorage penguin.sidebarPinnedGroups.<projectId>); order is
  // asserted geometrically, like layout.spec does for the login language buttons. ——
  // Sidebar-scoped for the same reason as the group assertion above: the draft's Workspace
  // pill would also read 临时工作区 whenever its selection is empty.
  const tempLabel = page.locator("aside").getByText("临时工作区", { exact: true });
  const namedLabel = page.getByText(wsLabel, { exact: true });
  const yOf = async (locator) => (await locator.boundingBox())?.y ?? -1;
  // Unpinned baseline: the merged temp group sits below the named group.
  await expect(tempLabel).toBeVisible();
  expect(await yOf(tempLabel)).toBeGreaterThan(await yOf(namedLabel));
  const tempHeader = tempLabel.locator("xpath=ancestor::div[contains(@class,'items-center')][1]");
  await tempHeader.hover();
  await tempHeader.getByRole("button", { name: "置顶分组" }).click();
  await expect
    .poll(() =>
      page.evaluate((k) => localStorage.getItem(k), `penguin.sidebarPinnedGroups.${projectId}`),
    )
    .toContain("temp-workspaces");
  await expect.poll(async () => (await yOf(tempLabel)) < (await yOf(namedLabel))).toBe(true);
  // Pinned state and order survive a reload. The pin button's accessible name is STATIC
  // ("置顶分组"); the state lives in aria-pressed alone (review: a name swapping to 取消置顶
  // alongside aria-pressed announces the state twice in conflicting ways).
  await page.reload();
  await expect(tempLabel).toBeVisible();
  await expect.poll(async () => (await yOf(tempLabel)) < (await yOf(namedLabel))).toBe(true);
  const tempPin = tempHeader.getByRole("button", { name: "置顶分组" });
  await expect(tempPin).toHaveAttribute("aria-pressed", "true");
  // The pinned pin doubles as the always-visible indicator. toBeVisible() ignores opacity,
  // so assert computed opacity directly: pinned = 1 with the mouse elsewhere; an unpinned
  // header's pin stays hover-gated at 0. Park the mouse first — after the reorder the named
  // header sits where the temp header was clicked, which would otherwise hover-reveal it.
  await page.mouse.move(640, 500);
  await expect.poll(() => tempPin.evaluate((el) => getComputedStyle(el).opacity)).toBe("1");
  await expect
    .poll(() =>
      wsHeader
        .getByRole("button", { name: "置顶分组" })
        .evaluate((el) => getComputedStyle(el).opacity),
    )
    .toBe("0");

  // —— Switch the sidebar to agent mode via the section-header toggle (persists in localStorage) ——
  await page.getByRole("button", { name: "按 Agent 分组" }).click();
  await expect
    .poll(() => page.evaluate(() => localStorage.getItem("penguin.sidebarGroupMode")))
    .toBe("agent");

  // —— Sidebar group-header "+": create a draft with that group's Agent (overrides the previously cached Agent) ——
  const helperHeader = page.getByText("Helper Agent", { exact: true }).first();
  await expect(helperHeader).toBeVisible();
  const groupRow = helperHeader.locator("xpath=ancestor::div[contains(@class,'items-center')][1]");
  await groupRow.getByRole("button", { name: "新建对话" }).click();

  await expect(page.getByLabel("选择 Agent")).toContainText("Helper Agent");
  await ta.fill("First message for helper");
  await page.getByRole("button", { name: "发送" }).click();
  await page.waitForURL(/\/chat\/session-/);
  const secondSessionId = page.url().split("/chat/")[1];
  expect(secondSessionId).not.toBe(firstSessionId);
  const second = await (await page.request.get(`${BASE}/api/sessions/${secondSessionId}`)).json();
  expect(second.session.agentId).toBe("agent_helper");
  // The previously picked model carries over as the new default (switch-becomes-default);
  // approval mode falls back to allow-all (the rest of the draft was cleared, so read-only doesn't linger).
  expect(second.session.modelId).toBe("claude-4-8-mini");
  expect(second.session.provider).toBe("custom");
  expect(second.session.approvalMode).toBe("allow-all");

  // Under allow-all, the mock's exec_command is auto-approved, so the round runs to completion (turn 2 text lands).
  await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible();

  // —— Input draft for an existing session: cached by user x Session, restored on reload, cleared once sending succeeds ——
  await ta.fill("Draft inside the session");
  const sessionKey = `penguin.chatDraft.session.${userId}.${secondSessionId}`;
  await expect
    .poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
    .toContain("Draft inside the session");

  await page.reload();
  await expect(ta).toHaveValue("Draft inside the session");
  await page.getByRole("button", { name: "发送" }).click();
  await expect.poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey)).toBeNull();

  // —— /model stages the fork; Enter is what performs it ——
  // Wait for the run above to finish first: the slash menu is suppressed while a Task runs, and
  // a staged fork deliberately refuses to send until the Session is idle (it continues from a
  // Trace the run is still appending to). Two signals, in order: the second round's closing
  // text lands (this Session has now answered two messages), then the action button stops being
  // Stop — which it is for as long as a Task runs with an empty composer.
  await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(2);
  await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);

  await ta.fill("/model");
  await ta.press("Enter");
  await expect(page.getByText("切换模型", { exact: true })).toBeVisible(); // picker title bar
  await expect(ta).toHaveValue(""); // the command consumed its own token
  await page.getByPlaceholder(/搜索模型/).fill("claude-4-8");
  // Both models match the query (one id is the other's prefix); pick the non-mini one, i.e. not
  // the model this Session already runs on.
  await page
    .getByRole("button", { name: /claude-4-8/ })
    .filter({ hasNotText: "mini" })
    .click();

  // Nothing was sent: we are still in the same Session, with the pick pinned as a chip — which
  // is why the body can be typed AFTER the pick and still ride along.
  await expect(page).toHaveURL(new RegExp(`/chat/${secondSessionId}$`));
  await expect(page.getByLabel("移除切换模型")).toBeVisible();
  await ta.fill("Fork body typed after the pick");

  // The chip is draft content, cached in the SAME entry as the text (poll on the text: it is
  // the debounced field, so its arrival means everything is flushed), and a reload restores
  // BOTH. A chip lost while its text survived would send that text to the current Session on
  // the old model — the opposite of what was staged.
  await expect
    .poll(() => page.evaluate((k) => localStorage.getItem(k), sessionKey))
    .toContain("Fork body typed after the pick");
  expect(await page.evaluate((k) => localStorage.getItem(k), sessionKey)).toContain("claude-4-8");
  await page.reload();
  await expect(ta).toHaveValue("Fork body typed after the pick");
  await expect(page.getByLabel("移除切换模型")).toBeVisible();

  // Enter performs the fork: a NEW Session on the picked model, same Agent, carrying the body.
  await ta.press("Enter");
  await page.waitForURL(
    (url) => /\/chat\/session-/.test(url.pathname) && !url.href.endsWith(secondSessionId),
  );
  const thirdSessionId = page.url().split("/chat/")[1];
  const third = await (await page.request.get(`${BASE}/api/sessions/${thirdSessionId}`)).json();
  expect(third.session.modelId).toBe("claude-4-8");
  expect(third.session.agentId).toBe("agent_helper");
  // The source block collapses into the "switched model" banner, and the typed body follows it.
  await expect(page.getByText(/已切换模型（原为 claude-4-8-mini）/)).toBeVisible();
  await expect(page.getByText("Fork body typed after the pick")).toBeVisible();

  // —— /agent stages the handoff; Enter is what performs it ——
  // The forked Session started its own run; wait it out the same way (a fresh stream, so its
  // first closing text is the only one).
  await expect(page.getByText("Command finished; the result looks as expected.")).toHaveCount(1);
  await expect(page.getByRole("button", { name: "停止" })).toHaveCount(0);
  await ta.fill("/agent");
  await ta.press("Enter");
  await page.getByPlaceholder(/搜索 Agent/).fill("default");
  await page.getByRole("button", { name: /default_agent/ }).click();
  // Staged only: still in the forked Session, still able to type the message to hand over.
  await expect(page).toHaveURL(new RegExp(`/chat/${thirdSessionId}$`));
  await expect(page.getByLabel("移除交接目标")).toBeVisible();
  await ta.fill("Handoff body typed after the pick");

  await ta.press("Enter");
  await page.waitForURL(
    (url) => /\/chat\/session-/.test(url.pathname) && !url.href.endsWith(thirdSessionId),
  );
  const fourthSessionId = page.url().split("/chat/")[1];
  const fourth = await (await page.request.get(`${BASE}/api/sessions/${fourthSessionId}`)).json();
  expect(fourth.session.agentId).toBe("default_agent");
  // The [handoff_from] block collapses into the origin banner (the source agent is named
  // without an @ sigil, matching the composer chip), and the typed body follows it.
  await expect(page.getByText(/由 .*agent_helper.* 的对话交接而来/)).toBeVisible();
  await expect(page.getByText("Handoff body typed after the pick")).toBeVisible();
});
