import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import type { UiMessage } from "@/stores/chat/ui-message";

import { ChatArea } from "../chat-area";

vi.mock("../message-bubble", () => ({
  MessageBubble: ({ content }: { content: string }) => <div>{content}</div>,
}));

let scrollHeight = 0;
let clientHeight = 0;
let resizeObservers: MockResizeObserver[] = [];

class MockResizeObserver {
  readonly callback: ResizeObserverCallback;

  constructor(callback: ResizeObserverCallback) {
    this.callback = callback;
    resizeObservers.push(this);
  }

  observe(): void {}
  disconnect(): void {}

  trigger(): void {
    this.callback([], this as unknown as ResizeObserver);
  }
}

describe("ChatArea scrolling", () => {
  let container: HTMLDivElement;
  let root: Root;

  beforeEach(() => {
    (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
      .IS_REACT_ACT_ENVIRONMENT = true;
    scrollHeight = 0;
    clientHeight = 0;
    resizeObservers = [];
    vi.stubGlobal("ResizeObserver", MockResizeObserver);
    vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
    vi.stubGlobal("cancelAnimationFrame", vi.fn());
    vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation(
      function (this: HTMLElement) {
        return this.getAttribute("aria-label") === "Chat messages"
          ? scrollHeight
          : 0;
      },
    );
    vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(
      function (this: HTMLElement) {
        return this.getAttribute("aria-label") === "Chat messages"
          ? clientHeight
          : 0;
      },
    );

    container = document.createElement("div");
    document.body.appendChild(container);
    root = createRoot(container);
  });

  afterEach(() => {
    act(() => root.unmount());
    container.remove();
    vi.restoreAllMocks();
    vi.unstubAllGlobals();
  });

  it("lands at the bottom on a normal entry", () => {
    scrollHeight = 900;
    clientHeight = 300;

    renderChatArea(root, [createMessage("history-1")], true);

    expect(getScrollNode(container).scrollTop).toBe(600);
  });

  it("waits for promotion bootstrap before settling at the final bottom", () => {
    scrollHeight = 800;
    clientHeight = 300;
    const history = [createMessage("history-1")];

    renderChatArea(root, history, false);
    expect(getScrollNode(container).scrollTop).toBe(0);

    scrollHeight = 1_200;
    renderChatArea(
      root,
      [...history, createMessage("promotion-1")],
      true,
    );

    expect(getScrollNode(container).scrollTop).toBe(900);
  });

  it("keeps the list bottom-anchored when rendered content grows", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    renderChatArea(root, [createMessage("history-1")], true);
    expect(getScrollNode(container).scrollTop).toBe(700);

    scrollHeight = 1_400;
    act(() => resizeObservers.forEach((observer) => observer.trigger()));

    expect(getScrollNode(container).scrollTop).toBe(1_100);
  });

  it("follows new messages when the user remains near the bottom", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    const history = [createMessage("history-1")];
    renderChatArea(root, history, true);
    const scrollNode = getScrollNode(container);
    const scrollTo = vi.fn();
    Object.defineProperty(scrollNode, "scrollTo", { value: scrollTo });

    scrollNode.scrollTop = 650;
    act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));

    scrollHeight = 1_400;
    renderChatArea(root, [...history, createMessage("reply-1")], true);

    expect(scrollNode.scrollTop).toBe(1_100);
    expect(scrollTo).not.toHaveBeenCalled();
  });

  it("preserves position after the user intentionally scrolls upward", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    const history = [createMessage("history-1")];
    renderChatArea(root, history, true);
    const scrollNode = getScrollNode(container);

    scrollNode.scrollTop = 100;
    act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));

    scrollHeight = 1_400;
    act(() => resizeObservers.forEach((observer) => observer.trigger()));
    renderChatArea(root, [...history, createMessage("reply-1")], true);

    expect(scrollNode.scrollTop).toBe(100);
  });

  it("forces the latest user message into view after the keyboard resizes", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    const history = [createMessage("history-1")];
    renderChatArea(root, history, true, { scrollToBottomSignal: 0 });
    const scrollNode = getScrollNode(container);

    clientHeight = 200;
    scrollNode.scrollTop = 700;
    act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));

    scrollHeight = 1_200;
    renderChatArea(
      root,
      [...history, createUserMessage("sent-1")],
      true,
      { scrollToBottomSignal: 1 },
    );

    expect(scrollNode.scrollTop).toBe(1_000);
  });

  it("scrolls below a promotion after the user sends a message", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    const history = [createMessage("history-1")];
    const promotion = createMessage("promotion-1");
    renderChatArea(root, [...history, promotion], true, {
      scrollToBottomSignal: 0,
    });
    const scrollNode = getScrollNode(container);
    scrollNode.scrollTop = 100;
    act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));

    scrollHeight = 1_500;
    renderChatArea(
      root,
      [...history, createUserMessage("sent-1"), promotion],
      true,
      { scrollToBottomSignal: 1 },
    );

    expect(scrollNode.scrollTop).toBe(1_200);
  });

  it("preserves the visible message after older history is prepended", () => {
    scrollHeight = 1_000;
    clientHeight = 300;
    const onLoadMoreHistory = vi.fn();
    const history = [createMessage("history-1")];
    renderChatArea(root, history, true, {
      canLoadMoreHistory: true,
      onLoadMoreHistory,
    });
    const scrollNode = getScrollNode(container);
    scrollNode.scrollTop = 0;
    act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true })));

    pull(scrollNode, 180);
    expect(onLoadMoreHistory).toHaveBeenCalledOnce();

    scrollHeight = 1_400;
    renderChatArea(
      root,
      [createMessage("older-1"), ...history],
      true,
      {
        canLoadMoreHistory: true,
        onLoadMoreHistory,
      },
    );

    expect(scrollNode.scrollTop).toBe(400);
  });
});

function renderChatArea(
  root: Root,
  messages: readonly UiMessage[],
  initialScrollReady: boolean,
  historyOptions: {
    canLoadMoreHistory?: boolean;
    isLoadingMoreHistory?: boolean;
    onLoadMoreHistory?: () => void;
    scrollToBottomSignal?: number;
  } = {},
): void {
  act(() => {
    root.render(
      <ChatArea
        characterId="elio"
        messages={messages}
        isReplyingAI={false}
        initialScrollReady={initialScrollReady}
        {...historyOptions}
      />,
    );
  });
}

function pull(target: HTMLElement, distance: number): void {
  act(() => {
    target.dispatchEvent(createTouchEvent("touchstart", 0));
    target.dispatchEvent(createTouchEvent("touchmove", distance));
    target.dispatchEvent(createTouchEvent("touchend", distance, true));
  });
}

function createTouchEvent(
  type: string,
  clientY: number,
  ended = false,
): Event {
  const event = new Event(type, { bubbles: true, cancelable: true });
  Object.defineProperty(event, "touches", {
    value: ended ? [] : [{ identifier: 1, clientY }],
  });
  return event;
}

function createMessage(displayId: string): UiMessage {
  return {
    displayId,
    content: `Message ${displayId}`,
    isFromAI: true,
    date: "2026-07-15",
  };
}

function createUserMessage(displayId: string): UiMessage {
  return {
    displayId,
    content: `Message ${displayId}`,
    isFromAI: false,
    date: "2026-07-15",
  };
}

function getScrollNode(container: HTMLElement): HTMLElement {
  const node = container.querySelector<HTMLElement>(
    '[aria-label="Chat messages"]',
  );
  if (!node) throw new Error("Chat scroll container was not rendered");
  return node;
}
