import type { Locator, Page, TestInfo } from "@playwright/test";
import { expect, gotoWorkbench, saveScreenshot, test } from "../fixtures/test";
import { selectors } from "../fixtures/selectors";

interface ScrollSample {
  t: number;
  scrollTop: number;
  clientHeight: number;
  scrollHeight: number;
  distanceToBottom: number;
  following: string | null;
  firstVisibleText: string | null;
  lastVisibleText: string | null;
}

test.describe("Workbench bottom-follow scrolling", () => {
  test.use({ scenarioId: "scroll-follow-long-trace" });

  test("Trace, main conversation, and raw Trace lists stay bottom-pinned without top flashes", async ({ page }, testInfo) => {
    await gotoWorkbench(page, "/workbench/sessions/ses_scroll_follow");

    const panel = page.locator(selectors.conversationList);
    const trace = page.locator(`${selectors.traceTimeline}[data-status="running"]`).last();
    await expect(trace).toBeVisible();
    await openTrace(trace);
    const traceList = trace.locator("ol");
    await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(70);

    await page.locator(`${selectors.messageCard}[data-role="agent"] .message-detail-button`).last().click();
    await expect(page.locator(selectors.messageDetailDialog)).toBeVisible();
    await page.locator(selectors.messageDetailDialog).getByRole("button", { name: "原始事件" }).click();
    const rawList = page.locator(".trace-raw-list");
    await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(70);

    await waitUntilScrollable(panel);
    await waitUntilScrollable(traceList);
    await waitUntilScrollable(rawList);
    await scrollToBottom(panel);
    await scrollToBottom(traceList);
    await scrollToBottom(rawList);
    await expect(panel).toHaveAttribute("data-following", "true");
    await expect(trace).toHaveAttribute("data-following", "true");
    await expect(rawList).toHaveAttribute("data-following", "true");
    await saveScreenshot(page, testInfo, "scroll-follow-bottom-before-append");

    const samplesPromise = collectScrollTimelines(page, {
      panel: selectors.conversationList,
      trace: `${selectors.traceTimeline}[data-status="running"] ol`,
      raw: ".trace-raw-list"
    }, 1800);
    await appendEvents(page, 4);
    await page.waitForTimeout(250);
    await appendEvents(page, 4);
    await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(90);
    await expect.poll(async () => await rawList.locator("li").count()).toBeGreaterThan(90);

    const samples = await samplesPromise;
    const panelSamples = samples.panel ?? [];
    const traceSamples = samples.trace ?? [];
    const rawSamples = samples.raw ?? [];
    await attachTimeline(testInfo, "conversation-panel-scroll-timeline", panelSamples);
    await attachTimeline(testInfo, "trace-list-scroll-timeline", traceSamples);
    await attachTimeline(testInfo, "raw-trace-scroll-timeline", rawSamples);
    assertNoTopFlash(panelSamples, "conversation-panel", 64);
    assertNoTopFlash(traceSamples, "trace-timeline", 32);
    assertNoTopFlash(rawSamples, "trace-raw-list", 32);
    await saveScreenshot(page, testInfo, "scroll-follow-bottom-after-append");

    await scrollToMiddle(traceList);
    await expect(trace).toHaveAttribute("data-following", "false");
    const beforePausedAppend = await readScroll(traceList);
    await appendEvents(page, 3);
    await expect.poll(async () => await traceList.locator("li").count()).toBeGreaterThan(100);
    const afterPausedAppend = await readScroll(traceList);
    expect(Math.abs(afterPausedAppend.scrollTop - beforePausedAppend.scrollTop)).toBeLessThanOrEqual(48);
    expect(afterPausedAppend.distanceToBottom).toBeGreaterThan(96);

    await scrollToBottom(traceList);
    await expect(trace).toHaveAttribute("data-following", "true");
    await appendEvents(page, 2);
    await expect.poll(async () => (await readScroll(traceList)).distanceToBottom).toBeLessThanOrEqual(32);
    await saveScreenshot(page, testInfo, "scroll-follow-user-pause-and-resume");
  });
});

async function openTrace(trace: Locator): Promise<void> {
  const details = trace.locator("details.trace-disclosure");
  const open = await details.evaluate((node) => (node as HTMLDetailsElement).open);
  if (!open) await trace.locator("summary.trace-disclosure-summary").click();
  await expect(details).toHaveJSProperty("open", true);
}

async function appendEvents(page: Page, count: number): Promise<void> {
  const response = await page.request.post("/__e2e/scroll-follow/append", { data: { count } });
  expect(response.ok()).toBeTruthy();
  const payload = await response.json() as { ok?: boolean; status?: string; eventCount?: number };
  expect(payload.ok).toBe(true);
  expect(payload.status).toBe("appended");
}

async function waitUntilScrollable(locator: Locator): Promise<void> {
  await expect.poll(async () => await locator.evaluate((node) => {
    const element = node as HTMLElement;
    return element.scrollHeight > element.clientHeight + 24;
  })).toBe(true);
}

async function scrollToBottom(locator: Locator): Promise<void> {
  await locator.evaluate((node) => {
    const element = node as HTMLElement;
    element.scrollTop = element.scrollHeight;
    element.dispatchEvent(new Event("scroll"));
  });
}

async function scrollToMiddle(locator: Locator): Promise<void> {
  await locator.evaluate((node) => {
    const element = node as HTMLElement;
    element.scrollTop = Math.max(0, Math.floor((element.scrollHeight - element.clientHeight) / 2));
    element.dispatchEvent(new Event("scroll"));
  });
}

async function readScroll(locator: Locator): Promise<Pick<ScrollSample, "scrollTop" | "clientHeight" | "scrollHeight" | "distanceToBottom">> {
  return await locator.evaluate((node) => {
    const element = node as HTMLElement;
    const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
    return { scrollTop: element.scrollTop, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, distanceToBottom: maxScrollTop - element.scrollTop };
  });
}

async function collectScrollTimelines(page: Page, targets: Record<string, string>, durationMs: number): Promise<Record<string, ScrollSample[]>> {
  return await page.evaluate(({ selectors: targetSelectors, duration }) => new Promise<Record<string, ScrollSample[]>>((resolve) => {
    const entries = Object.entries(targetSelectors).map(([name, selector]) => [name, document.querySelector(selector) as HTMLElement | null] as const);
    const samples = Object.fromEntries(entries.map(([name]) => [name, []])) as Record<string, ScrollSample[]>;
    const startedAt = performance.now();
    const visibleTexts = (element: HTMLElement): { first: string | null; last: string | null } => {
      const containerRect = element.getBoundingClientRect();
      const candidates = Array.from(element.querySelectorAll(":scope > li, :scope > article, .trace-render-row, .trace-raw-row")) as HTMLElement[];
      const visible = candidates.filter((item) => {
        const rect = item.getBoundingClientRect();
        return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
      }).map((item) => {
        const text = String(item.textContent ?? "").replace(/\s+/gu, " ").trim();
        return text ? text.slice(0, 120) : null;
      }).filter((value): value is string => Boolean(value));
      return { first: visible[0] ?? null, last: visible.at(-1) ?? null };
    };
    const collect = () => {
      const now = performance.now();
      for (const [name, element] of entries) {
        if (!element) continue;
        const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
        const visible = visibleTexts(element);
        samples[name]?.push({
          t: Math.round(now - startedAt),
          scrollTop: Math.round(element.scrollTop),
          clientHeight: Math.round(element.clientHeight),
          scrollHeight: Math.round(element.scrollHeight),
          distanceToBottom: Math.round(maxScrollTop - element.scrollTop),
          following: element.dataset.following ?? element.closest("[data-following]")?.getAttribute("data-following") ?? null,
          firstVisibleText: visible.first,
          lastVisibleText: visible.last
        });
      }
      if (now - startedAt >= duration) resolve(samples);
      else window.requestAnimationFrame(collect);
    };
    window.requestAnimationFrame(collect);
  }), { selectors: targets, duration: durationMs });
}

async function attachTimeline(testInfo: TestInfo, name: string, samples: ScrollSample[]): Promise<void> {
  await testInfo.attach(`${name}.json`, { body: JSON.stringify(samples, null, 2), contentType: "application/json" });
}

function assertNoTopFlash(samples: ScrollSample[], name: string, bottomThreshold: number): void {
  const scrollable = samples.filter((sample) => sample.scrollHeight > sample.clientHeight + 24);
  expect(scrollable.length, `${name} did not produce scrollable samples`).toBeGreaterThan(4);
  const maxScrollTop = Math.max(...scrollable.map((sample) => sample.scrollTop));
  expect(maxScrollTop, `${name} never sampled away from the top`).toBeGreaterThan(40);
  const topFlashes = scrollable.filter((sample, index) => index > 1 && maxScrollTop > 80 && sample.scrollTop <= 4);
  expect(topFlashes, `${name} flashed to top: ${JSON.stringify(topFlashes.slice(0, 3))}`).toEqual([]);
  const final = scrollable.at(-1);
  expect(final?.distanceToBottom ?? Number.POSITIVE_INFINITY, `${name} final sample was not bottom-pinned`).toBeLessThanOrEqual(bottomThreshold);
}
