import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

// jsdom does not provide ResizeObserver — stub it so MessageList can mount.
if (typeof globalThis.ResizeObserver === "undefined") {
  globalThis.ResizeObserver = class {
    observe(): void {
      /* noop */
    }
    unobserve(): void {
      /* noop */
    }
    disconnect(): void {
      /* noop */
    }
  } as unknown as typeof ResizeObserver;
}

import { createMessageList } from "@components/MessageList";
import type { MessageListOptions } from "@components/MessageList";
import { messagesStore } from "@stores/messages.store";
import { membersStore } from "@stores/members.store";
import type { Message } from "@stores/messages.store";

function resetStores(): void {
  messagesStore.setState(() => ({
    messagesByChannel: new Map(),
    pendingSends: new Map(),
    loadedChannels: new Set(),
    hasMore: new Map(),
    historyLoadState: new Map(),
    detachedChannels: new Set(),
  }));
  membersStore.setState(() => ({
    members: new Map(),
    typingUsers: new Map(),
  }));
}

function makeMessage(overrides: Partial<Message> & { id: number }): Message {
  return {
    channelId: 1,
    user: { id: 1, username: "Alice", avatar: null },
    content: `Message ${overrides.id}`,
    replyTo: null,
    attachments: [],
    reactions: [],
    pinned: false,
    editedAt: null,
    deleted: false,
    timestamp: "2024-01-15T12:00:00Z",
    status: "sent",
    correlationId: null,
    errorCode: null,
    ...overrides,
  };
}

function setMessages(channelId: number, messages: Message[]): void {
  messagesStore.setState((prev) => {
    const next = new Map(prev.messagesByChannel);
    next.set(channelId, messages);
    return { ...prev, messagesByChannel: next };
  });
}

function setHasMore(channelId: number, value: boolean): void {
  messagesStore.setState((prev) => {
    const next = new Map(prev.hasMore);
    next.set(channelId, value);
    return { ...prev, hasMore: next };
  });
}

function setHistoryLoadState(channelId: number, value: "loading" | "error"): void {
  messagesStore.setState((prev) => {
    const next = new Map(prev.historyLoadState);
    next.set(channelId, value);
    return { ...prev, historyLoadState: next };
  });
}

export type MessageListComponent = ReturnType<typeof createMessageList>;

describe("MessageList", () => {
  let container: HTMLDivElement;
  let msgList: MessageListComponent;
  let options: MessageListOptions;

  beforeEach(() => {
    resetStores();
    container = document.createElement("div");
    document.body.appendChild(container);
    options = {
      channelId: 1,
      channelName: "general",
      currentUserId: 1,
      onScrollTop: vi.fn(),
      onReplyClick: vi.fn(),
      onEditClick: vi.fn(),
      onDeleteClick: vi.fn(),
      onReactionClick: vi.fn(),
      onPinClick: vi.fn(),
    };
    msgList = createMessageList(options);
  });

  afterEach(() => {
    msgList.destroy?.();
    container.remove();
  });

  it("mounts with messages-container class", () => {
    msgList.mount(container);
    const root = container.querySelector(".messages-container");
    expect(root).not.toBeNull();
  });

  it("renders virtual scroll structure (spacers + content)", () => {
    msgList.mount(container);
    expect(container.querySelector(".virtual-spacer-top")).not.toBeNull();
    expect(container.querySelector(".virtual-content")).not.toBeNull();
    expect(container.querySelector(".virtual-spacer-bottom")).not.toBeNull();
  });

  it("renders messages from store", () => {
    const messages = [
      makeMessage({ id: 1, content: "Hello" }),
      makeMessage({ id: 2, content: "World" }),
    ];
    setMessages(1, messages);
    msgList.mount(container);

    const content = container.querySelector(".virtual-content");
    expect(content).not.toBeNull();
    // Should have rendered items (day divider + messages)
    expect(content!.children.length).toBeGreaterThan(0);
  });

  it("empty channel renders welcome state", () => {
    msgList.mount(container);
    const welcome = container.querySelector(".channel-welcome");
    expect(welcome).not.toBeNull();
    const title = container.querySelector(".channel-welcome-title");
    expect(title?.textContent).toBe("Welcome to #general!");
    const text = container.querySelector(".channel-welcome-text");
    expect(text?.textContent).toBe("This is the start of the #general channel.");
  });

  it("renders the in-region loading placeholder while history is loading", () => {
    setHistoryLoadState(1, "loading");
    msgList.mount(container);

    expect(container.querySelector(".messages-loading")).not.toBeNull();
    expect(container.querySelector(".channel-welcome")).toBeNull();
  });

  it("renders inline error with Retry on load failure, and Retry calls onRetryLoad", () => {
    const onRetryLoad = vi.fn();
    msgList.destroy?.();
    msgList = createMessageList({ ...options, onRetryLoad });
    setHistoryLoadState(1, "error");
    msgList.mount(container);

    expect(container.querySelector(".messages-load-error")).not.toBeNull();
    expect(container.querySelector(".channel-welcome")).toBeNull();
    const retry = container.querySelector("[data-testid='messages-retry']") as HTMLButtonElement;
    expect(retry).not.toBeNull();
    retry.click();
    expect(onRetryLoad).toHaveBeenCalledTimes(1);
  });

  it("transitions loading → welcome once the load state clears", () => {
    setHistoryLoadState(1, "loading");
    msgList.mount(container);
    expect(container.querySelector(".messages-loading")).not.toBeNull();

    messagesStore.setState((prev) => {
      const next = new Map(prev.historyLoadState);
      next.delete(1);
      return { ...prev, historyLoadState: next };
    });
    messagesStore.flush();

    expect(container.querySelector(".messages-loading")).toBeNull();
    expect(container.querySelector(".channel-welcome")).not.toBeNull();
  });

  it("destroy removes DOM and cleans up", () => {
    msgList.mount(container);
    expect(container.querySelector(".messages-container")).not.toBeNull();
    msgList.destroy?.();
    expect(container.querySelector(".messages-container")).toBeNull();
  });

  it("reacts to store updates", () => {
    msgList.mount(container);
    // Initially shows welcome state
    expect(container.querySelector(".channel-welcome")).not.toBeNull();

    // Add messages
    setMessages(1, [makeMessage({ id: 1, content: "New message" })]);
    messagesStore.flush();

    const content = container.querySelector(".virtual-content");
    expect(content!.children.length).toBeGreaterThan(0);
    // Welcome state should be gone once messages exist
    expect(container.querySelector(".channel-welcome")).toBeNull();
  });

  it("scrollToMessage returns true when message exists in virtual items", () => {
    const messages = [
      makeMessage({ id: 1, content: "Hello" }),
      makeMessage({ id: 2, content: "Target message" }),
      makeMessage({ id: 3, content: "World" }),
    ];
    setMessages(1, messages);
    msgList.mount(container);

    const result = msgList.scrollToMessage(2);
    expect(result).toBe(true);
  });

  it("scrollToMessage returns false when message not found", () => {
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const result = msgList.scrollToMessage(999);
    expect(result).toBe(false);
  });

  it("scrollToMessage flashes the target row so the eye can find it", () => {
    setMessages(1, [makeMessage({ id: 1 }), makeMessage({ id: 2 }), makeMessage({ id: 3 })]);
    msgList.mount(container);

    msgList.scrollToMessage(2);

    // A scroll with no visual marker leaves the reader hunting; the row the
    // jump landed on must be the one that flashes.
    const flashed = container.querySelector(".highlight-flash");
    expect(flashed).not.toBeNull();
    expect(flashed!.getAttribute("data-testid")).toBe("message-2");
  });

  it("scrollToMessage renders a target that was outside the rendered window", () => {
    // A long channel: without forcing a rebuild the target stays unrendered
    // and there is nothing to scroll to or flash.
    const many = Array.from({ length: 200 }, (_, i) => makeMessage({ id: i + 1 }));
    setMessages(1, many);
    msgList.mount(container);

    expect(msgList.scrollToMessage(150)).toBe(true);
    expect(container.querySelector('[data-testid="message-150"]')).not.toBeNull();
  });

  it("OC-0217/OC-0286: repeated jumps do not each register a permanent row listener on the component-lifetime signal", () => {
    // As a user clicking a reply bar's jump arrow, a search hit, or a pinned
    // entry repeatedly does across a live session.
    const messages = Array.from({ length: 10 }, (_, i) => makeMessage({ id: i + 1 }));
    setMessages(1, messages);
    msgList.mount(container);

    // Row listeners are registered as addEventListener(type, fn, { signal }).
    // Installed after mount() so it only observes what scrollToMessage does,
    // not mount's own (single, expected) registrations.
    const addEventListenerSpy = vi.spyOn(EventTarget.prototype, "addEventListener");

    /** Distinct AbortSignals handed to row listeners since the previous call. */
    function rowSignalsSinceLastRender(): AbortSignal[] {
      const signals = addEventListenerSpy.mock.calls
        .map((call) => call[2])
        .filter(
          (opts): opts is AddEventListenerOptions =>
            typeof opts === "object" && opts !== null && "signal" in opts,
        )
        .map((opts) => opts.signal)
        .filter((signal): signal is AbortSignal => signal != null);
      addEventListenerSpy.mockClear();
      return [...new Set(signals)];
    }

    const windowSignals: AbortSignal[] = [];
    for (let i = 1; i <= 5; i++) {
      expect(msgList.scrollToMessage(i)).toBe(true);
      const [signal, ...extra] = rowSignalsSinceLastRender();
      // Every row in a rendered window shares that window's single signal.
      expect(extra).toHaveLength(0);
      if (signal === undefined) throw new Error(`jump ${i} rendered no row listeners`);
      windowSignals.push(signal);
    }

    // Each jump renders against a fresh signal, so nothing accumulates row
    // listeners on one long-lived signal.
    expect(new Set(windowSignals).size).toBe(windowSignals.length);

    // A superseded window is released by the render that replaced it, not
    // deferred to destroy(). Before OC-0286 rows registered directly against
    // the component-lifetime signal (`ac.signal`), so all five of these would
    // still be live here, each pinning a whole window of detached rows and
    // everything they reference — videos, images, embeds, tooltips.
    const superseded = windowSignals.slice(0, -1);
    const current = windowSignals[windowSignals.length - 1];
    expect(superseded.every((signal) => signal.aborted)).toBe(true);
    expect(current?.aborted).toBe(false);

    addEventListenerSpy.mockRestore();
  });

  it("rebuilds the virtual window when scrolling outside the rendered range", async () => {
    setHasMore(1, false);
    const many = Array.from({ length: 300 }, (_, i) => makeMessage({ id: i + 1 }));
    setMessages(1, many);
    msgList.mount(container);

    // renderAll positions the window at the tail; rows near the top are
    // virtualized away behind the top spacer.
    expect(container.querySelector('[data-testid="message-1"]')).toBeNull();
    expect(container.querySelector('[data-testid="message-300"]')).not.toBeNull();

    // mount's trailing scrollToBottom leaves scrollTop at 0 in jsdom
    // (scrollHeight is 0 without layout), so the scroll position now sits at
    // the very top of the list while the rendered window is still the tail —
    // exactly the state a user scrolling far past the overscan produces.
    const root = container.querySelector(".messages-container") as HTMLDivElement;
    expect(root.scrollTop).toBe(0);
    root.dispatchEvent(new Event("scroll"));
    await new Promise((resolve) => requestAnimationFrame(resolve));

    // The window must follow the scroll: rows at the top render, and the old
    // tail rows are released back to the spacers.
    expect(container.querySelector('[data-testid="message-1"]')).not.toBeNull();
    expect(container.querySelector('[data-testid="message-300"]')).toBeNull();
  });

  it("renders day dividers between messages on different days", () => {
    const messages = [
      makeMessage({ id: 1, timestamp: "2024-01-15T12:00:00Z" }),
      makeMessage({ id: 2, timestamp: "2024-01-16T12:00:00Z" }),
    ];
    setMessages(1, messages);
    msgList.mount(container);

    // Virtual scroll in jsdom has no real layout (clientHeight=0),
    // so we verify content was rendered at all — the render window
    // may include all items since offsetToIndex returns 0-based for
    // zero-height containers. Check for msg-day-divider class.
    const content = container.querySelector(".virtual-content");
    expect(content).not.toBeNull();
    // The virtual scroll renders items based on estimated heights.
    // In jsdom with 0 clientHeight, renderWindow computes start=0, end=OVERSCAN+1.
    // With only 4 items (2 dividers + 2 messages), all should be in the window.
    const dividers = container.querySelectorAll(".msg-day-divider");
    expect(dividers.length).toBe(2);
  });

  it("day divider breaks grouping even for the same author posting across midnight", () => {
    // isSameDay compares local calendar days, so the boundary is built with
    // the local-time Date constructor (not UTC ISO literals) to stay
    // independent of the machine/CI runner's timezone.
    const beforeMidnight = new Date(2024, 0, 15, 23, 58, 0).toISOString();
    const afterMidnight = new Date(2024, 0, 16, 0, 1, 0).toISOString();
    const messages = [
      makeMessage({
        id: 1,
        user: { id: 1, username: "Alice", avatar: null },
        timestamp: beforeMidnight,
      }),
      makeMessage({
        id: 2,
        user: { id: 1, username: "Alice", avatar: null },
        timestamp: afterMidnight,
      }),
    ];
    setMessages(1, messages);
    msgList.mount(container);

    // 2 dividers: the leading one before the first message, plus one for the
    // day change (matches "renders day dividers between messages on
    // different days" above -- the assertion here is on grouping, not count).
    expect(container.querySelectorAll(".msg-day-divider").length).toBe(2);
    const row2 = container.querySelector("[data-testid='message-2']")!;
    expect(row2.classList.contains("grouped")).toBe(false);
  });

  it("renders DM channel empty state differently from text channels", () => {
    msgList.destroy?.();
    const dmOptions: MessageListOptions = {
      ...options,
      channelName: "Bob",
      channelType: "dm",
    };
    msgList = createMessageList(dmOptions);
    msgList.mount(container);

    const title = container.querySelector(".channel-welcome-title");
    expect(title?.textContent).toBe("Bob");

    const icon = container.querySelector(".channel-welcome-icon");
    expect(icon?.textContent).toBe("@");

    const text = container.querySelector(".channel-welcome-text");
    expect(text?.textContent).toBe(
      "This is the beginning of your direct message history with Bob.",
    );
  });

  it("includes a scroll-to-bottom button", () => {
    msgList.mount(container);
    const btn = container.querySelector(".scroll-to-bottom-btn");
    expect(btn).not.toBeNull();
    expect(btn?.textContent).toBe("\u2193");
  });

  it("anchors the floating controls outside the scroller so they cannot scroll away", () => {
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const scroller = container.querySelector(".messages-container") as HTMLDivElement;
    const btn = container.querySelector(".scroll-to-bottom-btn") as HTMLButtonElement;
    const pill = container.querySelector('[data-testid="jump-to-present"]') as HTMLButtonElement;
    expect(scroller).not.toBeNull();
    expect(btn).not.toBeNull();
    expect(pill).not.toBeNull();

    // Anything inside the overflow scroller is part of its scrollable
    // overflow and translates with the content, so the controls must not be
    // descendants of it.
    expect(scroller.contains(btn)).toBe(false);
    expect(scroller.contains(pill)).toBe(false);

    // They anchor to the component's non-scrolling frame around the scroller
    // (the positioned containing block that keeps them pinned to the
    // viewport edge).
    const region = scroller.parentElement as HTMLDivElement;
    expect(region.classList.contains("messages-region")).toBe(true);
    expect(container.contains(region)).toBe(true);
    expect(btn.parentElement).toBe(region);
    expect(pill.parentElement).toBe(region);

    // destroy removes the frame — and with it the controls — not just the
    // scroller.
    msgList.destroy?.();
    expect(container.querySelector(".messages-region")).toBeNull();
    expect(container.querySelector(".scroll-to-bottom-btn")).toBeNull();
    expect(container.querySelector('[data-testid="jump-to-present"]')).toBeNull();
  });

  it("calls onScrollTop when scrolling near the top and there are more messages", () => {
    setHasMore(1, true);
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    // jsdom scrollTop defaults to 0 which is already < SCROLL_TOP_THRESHOLD(50)
    // Manually trigger the scroll event
    root.dispatchEvent(new Event("scroll"));

    expect(options.onScrollTop).toHaveBeenCalledOnce();
  });

  it("does not call onScrollTop when no more messages are available", () => {
    setHasMore(1, false);
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    root.dispatchEvent(new Event("scroll"));

    expect(options.onScrollTop).not.toHaveBeenCalled();
  });

  it("does not call onScrollTop twice without new messages arriving", () => {
    setHasMore(1, true);
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    root.dispatchEvent(new Event("scroll"));
    root.dispatchEvent(new Event("scroll"));

    // loadingOlder guard prevents double-calling
    expect(options.onScrollTop).toHaveBeenCalledTimes(1);
  });

  it("resets loadingOlder flag when new messages arrive after scroll-top", () => {
    setHasMore(1, true);
    setMessages(1, [makeMessage({ id: 1 })]);
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    root.dispatchEvent(new Event("scroll"));
    expect(options.onScrollTop).toHaveBeenCalledTimes(1);

    // Simulate new messages arriving (load-more response)
    setMessages(1, [makeMessage({ id: 0, content: "Older message" }), makeMessage({ id: 1 })]);
    messagesStore.flush();

    // Now scrolling to top again should trigger onScrollTop again
    root.dispatchEvent(new Event("scroll"));
    expect(options.onScrollTop).toHaveBeenCalledTimes(2);
  });

  it("clears loadingOlder once the onScrollTop promise settles, even when no new messages arrived (failed fetch)", async () => {
    setHasMore(1, true);
    setMessages(1, [makeMessage({ id: 1 })]);
    // Flush this setup notification now — store notifications are deferred to
    // a microtask, and without this it would land during the first `await`
    // below and reset loadingOlder for an unrelated reason (prevMessageCount
    // syncing from its initial 0), masking the bug this test targets.
    messagesStore.flush();
    let resolveLoad: () => void = () => {};
    const onScrollTop = vi.fn(
      () =>
        new Promise<void>((resolve) => {
          resolveLoad = resolve;
        }),
    );
    // Recreate with the overriding onScrollTop — it's declared readonly, so
    // it must be set at construction rather than mutated on the shared
    // `options` object from beforeEach.
    msgList = createMessageList({ ...options, onScrollTop });
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    root.dispatchEvent(new Event("scroll"));
    expect(onScrollTop).toHaveBeenCalledTimes(1);

    // A second scroll-to-top while the fetch is still in flight must not
    // re-trigger it.
    root.dispatchEvent(new Event("scroll"));
    expect(onScrollTop).toHaveBeenCalledTimes(1);

    // The fetch settles WITHOUT any new messages arriving — the failure path
    // (a real onScrollTop catches its own error and never rejects, so the
    // promise resolves either way; the store just never changed).
    resolveLoad();
    await Promise.resolve();
    await Promise.resolve();

    // loadingOlder must now be false — scrolling to top again re-triggers it.
    root.dispatchEvent(new Event("scroll"));
    expect(onScrollTop).toHaveBeenCalledTimes(2);
  });

  it("does not re-trigger onScrollTop from a live tail append while a history fetch is in flight", async () => {
    setHasMore(1, true);
    setMessages(1, [makeMessage({ id: 1 })]);
    messagesStore.flush();

    let resolveLoad: () => void = () => {};
    const onScrollTop = vi.fn(
      () =>
        new Promise<void>((resolve) => {
          resolveLoad = resolve;
        }),
    );
    msgList = createMessageList({ ...options, onScrollTop });
    msgList.mount(container);

    const root = container.querySelector(".messages-container") as HTMLDivElement;
    root.dispatchEvent(new Event("scroll"));
    expect(onScrollTop).toHaveBeenCalledTimes(1);

    // A live message arrives at the tail while the older-page fetch is still
    // in flight. messages[0] (the oldest loaded message) is unchanged, so the
    // latch must stay set -- otherwise the next scroll refires the fetch with
    // the same unchanged cursor and the same page lands twice.
    setMessages(1, [
      ...(messagesStore.getState().messagesByChannel.get(1) ?? []),
      makeMessage({ id: 2 }),
    ]);
    messagesStore.flush();

    root.dispatchEvent(new Event("scroll"));
    expect(onScrollTop).toHaveBeenCalledTimes(1);

    resolveLoad();
    await Promise.resolve();
  });

  it("scrollToMessage returns false before mount", () => {
    // scrollToMessage should be safe to call before mount
    const unmounted = createMessageList(options);
    expect(unmounted.scrollToMessage(1)).toBe(false);
    unmounted.destroy?.();
  });

  it("groups consecutive messages from the same user within threshold", () => {
    // Two messages from same user within 5 minutes
    const messages = [
      makeMessage({
        id: 1,
        user: { id: 1, username: "Alice", avatar: null },
        timestamp: "2024-01-15T12:00:00Z",
        content: "First message",
      }),
      makeMessage({
        id: 2,
        user: { id: 1, username: "Alice", avatar: null },
        timestamp: "2024-01-15T12:01:00Z",
        content: "Second message",
      }),
    ];
    setMessages(1, messages);
    msgList.mount(container);

    const content = container.querySelector(".virtual-content");
    expect(content).not.toBeNull();
    // Both messages should render; the second should be grouped (class "message grouped")
    const grouped = content!.querySelectorAll(".message.grouped");
    expect(grouped.length).toBeGreaterThanOrEqual(1);
  });

  it("destroys cleanly without errors even with loaded messages", () => {
    setMessages(1, [makeMessage({ id: 1 }), makeMessage({ id: 2 })]);
    msgList.mount(container);
    expect(container.querySelector(".messages-container")).not.toBeNull();

    // destroy should not throw
    expect(() => msgList.destroy?.()).not.toThrow();
    expect(container.querySelector(".messages-container")).toBeNull();
  });

  it("does not re-render when a DIFFERENT channel's messages update", () => {
    setMessages(1, [makeMessage({ id: 1, content: "Mine" })]);
    msgList.mount(container);

    const rowBefore = container.querySelector("[data-testid='message-1']");
    expect(rowBefore).not.toBeNull();

    // Update another channel — this list (channel 1) must not rebuild.
    setMessages(2, [makeMessage({ id: 50, channelId: 2, content: "Other channel" })]);
    messagesStore.flush();

    const rowAfter = container.querySelector("[data-testid='message-1']");
    expect(rowAfter).toBe(rowBefore); // same element instance — no re-render
  });

  describe("incremental tail append", () => {
    it("appends new rows without rebuilding existing ones", () => {
      setMessages(1, [
        makeMessage({ id: 1, content: "First" }),
        makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
      ]);
      msgList.mount(container);

      const row1Before = container.querySelector("[data-testid='message-1']");
      const row2Before = container.querySelector("[data-testid='message-2']");
      expect(row1Before).not.toBeNull();
      expect(row2Before).not.toBeNull();

      // Pure suffix extension → fast path: existing rows keep their identity.
      setMessages(1, [
        ...(messagesStore.getState().messagesByChannel.get(1) ?? []),
        makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }),
      ]);
      messagesStore.flush();

      expect(container.querySelector("[data-testid='message-1']")).toBe(row1Before);
      expect(container.querySelector("[data-testid='message-2']")).toBe(row2Before);
      expect(container.querySelector("[data-testid='message-3']")).not.toBeNull();
    });

    it("appended rows preserve order, grouping, and day dividers vs a full rebuild", () => {
      const initial = [
        makeMessage({ id: 1, content: "First", timestamp: "2024-01-15T12:00:00Z" }),
        makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
      ];
      setMessages(1, initial);
      msgList.mount(container);

      const appended = [
        // Same user within threshold → must render grouped.
        makeMessage({ id: 3, content: "Third", timestamp: "2024-01-15T12:02:00Z" }),
        // Next day, different user → must be preceded by a day divider.
        makeMessage({
          id: 4,
          content: "Fourth",
          user: { id: 2, username: "Bob", avatar: null },
          timestamp: "2024-01-16T09:00:00Z",
        }),
      ];
      const finalMessages = [...initial, ...appended];
      setMessages(1, finalMessages);
      messagesStore.flush();

      const content = container.querySelector(".virtual-content")!;

      // Reference render: a fresh list mounted with the final message set
      // (full rebuild path) must produce the same structure.
      const refContainer = document.createElement("div");
      document.body.appendChild(refContainer);
      const refList = createMessageList(options);
      refList.mount(refContainer);
      const refContent = refContainer.querySelector(".virtual-content")!;

      const describeChildren = (el: Element): string[] =>
        Array.from(el.children).map((c) => `${c.className}|${c.getAttribute("data-testid") ?? ""}`);
      expect(describeChildren(content)).toEqual(describeChildren(refContent));

      // Explicit semantic checks on the appended tail.
      expect(container.querySelectorAll(".msg-day-divider").length).toBe(2);
      const row3 = container.querySelector("[data-testid='message-3']")!;
      expect(row3.classList.contains("grouped")).toBe(true);
      const row4 = container.querySelector("[data-testid='message-4']")!;
      expect(row4.classList.contains("grouped")).toBe(false);
      const ids = Array.from(content.querySelectorAll("[data-testid^='message-']")).map((el) =>
        el.getAttribute("data-testid"),
      );
      expect(ids).toEqual(["message-1", "message-2", "message-3", "message-4"]);

      refList.destroy?.();
      refContainer.remove();
    });

    it("falls back to a full rebuild for non-append updates (edit)", () => {
      setMessages(1, [
        makeMessage({ id: 1, content: "Original" }),
        makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
      ]);
      msgList.mount(container);

      // Replace message 1's object (an edit) — not a suffix extension.
      setMessages(1, [
        makeMessage({ id: 1, content: "Edited" }),
        makeMessage({ id: 2, content: "Second", timestamp: "2024-01-15T12:01:00Z" }),
      ]);
      messagesStore.flush();

      const row1 = container.querySelector("[data-testid='message-1']");
      expect(row1).not.toBeNull();
      expect(row1!.textContent).toContain("Edited");
    });
  });

  describe("renderAll rapid-fire breaker", () => {
    beforeEach(() => {
      vi.useFakeTimers();
    });

    afterEach(() => {
      vi.useRealTimers();
    });

    it("renders the final state once the 2s burst window resets, instead of staying stuck at the pre-trip state", () => {
      setMessages(1, [makeMessage({ id: 1, content: "v0" })]);
      msgList.mount(container); // 1st renderAll call, starts the 2s window

      // Fire 25 non-append updates (edits) back-to-back, well inside the 2s
      // window. tryAppendMessages() returns false for every one of these
      // (same-length array, content changed) so each forces a renderAll().
      // Combined with the mount's call, this is 26 renderAll invocations —
      // calls 21+ trip the >20-in-2s breaker and must return without
      // rendering.
      for (let i = 1; i <= 25; i++) {
        setMessages(1, [makeMessage({ id: 1, content: `v${i}` })]);
        messagesStore.flush();
      }

      const rowDuringBurst = container.querySelector("[data-testid='message-1']");
      expect(rowDuringBurst).not.toBeNull();
      // The breaker tripped partway through, so the DOM is stuck behind the
      // final store state (still showing an earlier version, not "v25").
      expect(rowDuringBurst!.textContent).not.toContain("v25");

      // Let the 2s reset window elapse with no further store updates.
      vi.advanceTimersByTime(2100);

      // Once the burst is over, the list must reflect the final state that
      // triggered the last suppressed renderAll — not stay frozen on
      // whatever rendered right before the breaker tripped.
      const rowAfterReset = container.querySelector("[data-testid='message-1']");
      expect(rowAfterReset).not.toBeNull();
      expect(rowAfterReset!.textContent).toContain("v25");
    });
  });

  describe("scrollToMessage vs renderWindow rebuild breaker", () => {
    beforeEach(() => {
      vi.useFakeTimers();
    });

    afterEach(() => {
      vi.useRealTimers();
    });

    // 30 synchronous full renderWindow rebuilds of a 100-row list can exceed
    // vitest's default 5s on a loaded CI runner (audit-2026-08-19 F-5), so
    // this test carries its own timeout.
    it(
      "does not report success when renderWindow's own >30-in-2s breaker drops the rebuild",
      { timeout: 20_000 },
      () => {
        const many = Array.from({ length: 100 }, (_, i) => makeMessage({ id: i + 1 }));
        setMessages(1, many);
        msgList.mount(container); // mount's renderAll -> renderWindow: rebuild count = 1

        // Every scrollToMessage call forces renderedStart = -1 and calls
        // renderWindow() directly, bypassing renderAll's own (lower) rapid-fire
        // limit. 29 more calls bring the shared renderWindow rebuild counter to
        // 30 (still under the >30 breaker), each one landing normally.
        for (let i = 0; i < 29; i++) {
          expect(msgList.scrollToMessage(i + 1)).toBe(true);
        }

        // The 30th call pushes the counter to 31 and trips the breaker inside
        // renderWindow: it returns before reassigning renderedStart/renderedEnd
        // or touching the DOM, so the target (far outside the last rendered
        // window) never actually renders.
        const result = msgList.scrollToMessage(90);

        // The rebuild did not happen — the row is not in the DOM — so this must
        // be reported as a failed jump (matching the "false if the message is
        // not in the loaded window" contract), not a false "true".
        expect(container.querySelector('[data-testid="message-90"]')).toBeNull();
        expect(result).toBe(false);
      },
    );
  });
});
