/**
 * Tests for src/components/channel-sidebar/drag-reorder.ts.
 *
 * This module was the least-covered file in the client (38.8% statements) and
 * had no test of its own — the reorder index arithmetic, the admin-only gate,
 * the 5px drag threshold and the listener ref-counting were all unverified.
 * Off-by-one errors in the insert index silently reorder the wrong pair of
 * channels for every member of the server.
 */

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

import {
  attachDragHandlers,
  ensureGlobalDragListeners,
} from "@components/channel-sidebar/drag-reorder";
import type { ChannelReorderData } from "@components/ChannelSidebar";
import { authStore } from "@stores/auth.store";
import { channelsStore, setRoles } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { UserWithRole } from "@lib/types";
import { Permission } from "@lib/types";

// ── helpers ────────────────────────────────────────────────────────────────

function makeUser(role: string): UserWithRole {
  return { id: 1, username: "tester", avatar: null, role };
}

function signIn(role: string): void {
  authStore.setState(() => ({
    token: "t",
    user: makeUser(role),
    serverName: "s",
    motd: "",
    isAuthenticated: true,
  }));
}

function makeCh(id: number, position: number, name = `ch-${id}`): Channel {
  return {
    id,
    name,
    type: "text",
    category: null,
    position,
    unreadCount: 0,
    mentionCount: 0,
    lastMessageId: null,
    canSend: true,
    topic: "",
    slowMode: 0,
    nsfw: false,
    voiceMaxUsers: 0,
    voiceMaxVideo: 0,
  };
}

interface Rig {
  container: HTMLElement;
  items: Map<number, HTMLElement>;
  channels: Channel[];
  onReorder: Mock<(reorders: readonly ChannelReorderData[]) => void>;
  abort: AbortController;
}

/** Every rig's owner controller, aborted in afterEach so the shared document
 *  listeners are fully torn down between tests. */
const rigAborts: AbortController[] = [];

/** jsdom does not lay out, so stub the geometry the module reads: a 20px-tall
 *  row at y = idx*20 while attached, and — exactly like a real browser — an
 *  all-zero rect once the row is detached (e.g. a re-render replaced it). */
function stubRowRect(el: HTMLElement, idx: number): void {
  const top = idx * 20;
  el.getBoundingClientRect = () =>
    el.isConnected
      ? {
          top,
          bottom: top + 20,
          height: 20,
          left: 0,
          right: 100,
          width: 100,
          x: 0,
          y: top,
          toJSON: () => ({}),
        }
      : {
          top: 0,
          bottom: 0,
          height: 0,
          left: 0,
          right: 0,
          width: 0,
          x: 0,
          y: 0,
          toJSON: () => ({}),
        };
}

/** Builds a container with one 20px-tall row per channel, stacked vertically. */
function buildRig(channels: Channel[]): Rig {
  const container = document.createElement("div");
  document.body.appendChild(container);

  const items = new Map<number, HTMLElement>();
  const onReorder = vi.fn<(reorders: readonly ChannelReorderData[]) => void>();
  const abort = new AbortController();
  rigAborts.push(abort);

  channels.forEach((ch, idx) => {
    const el = document.createElement("div");
    container.appendChild(el);
    stubRowRect(el, idx);
    attachDragHandlers(el, ch, container, channels, abort.signal, abort.signal, onReorder);
    items.set(ch.id, el);
  });

  return { container, items, channels, onReorder, abort };
}

/** Simulates ChannelSidebar.renderChannels() rebuilding a category group:
 *  fresh rows in a fresh `.category-channels-container` under the same
 *  sidebar owner, while the previous container sits detached. */
function rebuildContainer(
  rig: Rig,
  channels: Channel[],
): { container: HTMLElement; items: Map<number, HTMLElement> } {
  const container = document.createElement("div");
  container.className = "category-channels-container";
  document.body.appendChild(container);

  const items = new Map<number, HTMLElement>();
  channels.forEach((ch, idx) => {
    const el = document.createElement("div");
    container.appendChild(el);
    stubRowRect(el, idx);
    attachDragHandlers(
      el,
      ch,
      container,
      channels,
      rig.abort.signal,
      rig.abort.signal,
      rig.onReorder,
    );
    items.set(ch.id, el);
  });

  return { container, items };
}

/** Row `idx` spans y = idx*20 .. idx*20+20; its midpoint is +10. */
function yInRow(idx: number, half: "top" | "bottom"): number {
  return idx * 20 + (half === "top" ? 4 : 16);
}

/** A real drag holds the left button down for its whole duration, so `buttons`
 *  (the held-button bitmask) is set alongside `button` (which button changed)
 *  on every synthetic event — mousemove gates on `buttons` to detect a stale
 *  latch left by an off-row release. */
function mouse(type: string, clientX: number, clientY: number): MouseEvent {
  return new MouseEvent(type, { clientX, clientY, button: 0, buttons: 1, bubbles: true });
}

/** Drives a full drag of `fromId` onto the given half of row `toIdx`. */
function drag(rig: Rig, fromId: number, toIdx: number, half: "top" | "bottom"): void {
  const source = rig.items.get(fromId);
  if (source === undefined) throw new Error(`no row for channel ${fromId}`);
  const fromIdx = rig.channels.findIndex((c) => c.id === fromId);

  source.dispatchEvent(mouse("mousedown", 0, yInRow(fromIdx, "top")));
  // Past the 5px threshold so the drag activates.
  source.dispatchEvent(mouse("mousemove", 0, yInRow(fromIdx, "top") + 20));
  document.dispatchEvent(mouse("mouseup", 0, yInRow(toIdx, half)));
}

beforeEach(() => {
  authStore.setState(() => ({
    token: null,
    user: null,
    serverName: null,
    motd: null,
    isAuthenticated: false,
  }));
  channelsStore.setState(() => ({ channels: new Map(), activeChannelId: null, roles: [] }));
  document.body.className = "";
  document.body.innerHTML = "";
});

afterEach(() => {
  // End any drag still in flight so it cannot leak into the next test.
  // Re-arm the global handlers under a throwaway owner first: a test that
  // aborted every owner tore them down, and the mouseup below needs a live
  // listener to do the clearing.
  const flush = new AbortController();
  ensureGlobalDragListeners(flush.signal);
  document.dispatchEvent(mouse("mouseup", 0, -1000));
  flush.abort();
  // Abort every rig owner so the shared document listeners are torn down.
  for (const ac of rigAborts) ac.abort();
  rigAborts.length = 0;
  document.body.innerHTML = "";
  document.body.className = "";
});

// ── permission gate ────────────────────────────────────────────────────────

describe("attachDragHandlers permission gate", () => {
  it.each([
    ["owner", true],
    ["admin", true],
    ["Owner", true],
    ["ADMIN", true],
    ["moderator", false],
    ["member", false],
    ["", false],
  ])("role %s → draggable %s", (role, draggable) => {
    signIn(role);
    const rig = buildRig([makeCh(1, 0)]);
    const el = rig.items.get(1);

    expect(el?.classList.contains("channel-draggable")).toBe(draggable);
  });

  // Gated on the MANAGE_CHANNELS bit, not the literal role name — the same
  // derivation as Edit/Delete (context-menu.ts) and the category "+"
  // (ChannelSidebar.ts), so the three cannot drift apart on who may reorder.
  it("makes a custom role holding MANAGE_CHANNELS draggable, even though its name is neither owner nor admin", () => {
    setRoles([{ id: 9, name: "Curator", color: null, permissions: Permission.MANAGE_CHANNELS }]);
    signIn("Curator");
    const rig = buildRig([makeCh(1, 0)]);

    expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(true);
  });

  it("does not make a role literally named 'admin' draggable when it lacks MANAGE_CHANNELS", () => {
    setRoles([{ id: 9, name: "admin", color: null, permissions: 0 }]);
    signIn("admin");
    const rig = buildRig([makeCh(1, 0)]);

    expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false);
  });

  it("does nothing when no user is signed in", () => {
    const rig = buildRig([makeCh(1, 0)]);

    expect(rig.items.get(1)?.classList.contains("channel-draggable")).toBe(false);
  });

  it("does nothing when no onReorderChannel callback is supplied", () => {
    signIn("owner");
    const container = document.createElement("div");
    document.body.appendChild(container);
    const el = document.createElement("div");
    container.appendChild(el);

    const noopAc = new AbortController();
    attachDragHandlers(el, makeCh(1, 0), container, [makeCh(1, 0)], noopAc.signal, noopAc.signal);

    expect(el.classList.contains("channel-draggable")).toBe(false);
    expect(el.dataset.dragChannelId).toBeUndefined();
  });

  it("stamps the channel id for hit-testing when permitted", () => {
    signIn("admin");
    const rig = buildRig([makeCh(7, 0)]);

    expect(rig.items.get(7)?.dataset.dragChannelId).toBe("7");
  });
});

// ── drag threshold ─────────────────────────────────────────────────────────

describe("drag activation threshold", () => {
  it("does not start a drag below the 5px threshold", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const el = rig.items.get(1);

    el?.dispatchEvent(mouse("mousedown", 0, 4));
    el?.dispatchEvent(mouse("mousemove", 2, 6)); // dx+dy = 4

    expect(el?.classList.contains("dragging")).toBe(false);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });

  it("starts a drag once movement exceeds the threshold", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const el = rig.items.get(1);

    el?.dispatchEvent(mouse("mousedown", 0, 4));
    el?.dispatchEvent(mouse("mousemove", 3, 10)); // dx+dy = 9

    expect(el?.classList.contains("dragging")).toBe(true);
    expect(document.body.classList.contains("channel-reordering")).toBe(true);
  });

  it("ignores non-left mouse buttons", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const el = rig.items.get(1);

    el?.dispatchEvent(new MouseEvent("mousedown", { clientX: 0, clientY: 4, button: 2 }));
    el?.dispatchEvent(mouse("mousemove", 0, 40));

    expect(el?.classList.contains("dragging")).toBe(false);
  });

  it("a mouseup before the threshold cancels the pending drag", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const el = rig.items.get(1);

    el?.dispatchEvent(mouse("mousedown", 0, 4));
    el?.dispatchEvent(mouse("mouseup", 0, 4));
    el?.dispatchEvent(mouse("mousemove", 0, 60));

    expect(el?.classList.contains("dragging")).toBe(false);
  });

  it("a stale pending-drag latch left by an off-row release does not resume on a later buttonless hover", () => {
    // Press near row 1, drift under the 5px threshold, and release over row 2:
    // no drag ran, and because mousedown/mouseup targeted different elements,
    // row 1's own mouseup listener never fires, so its pendingDrag latch is
    // never cleared.
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
    const row1 = rig.items.get(1)!;

    row1.dispatchEvent(mouse("mousedown", 0, yInRow(0, "bottom")));
    row1.dispatchEvent(mouse("mousemove", 0, yInRow(0, "bottom") + 1)); // 1px, below threshold
    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top"))); // released over row 2

    expect(row1.classList.contains("dragging")).toBe(false);

    // Later, the pointer crosses row 1 again with no button held (a plain
    // hover). Without the fix this satisfies the 5px threshold against the
    // stale startX/startY and silently starts a real drag.
    row1.dispatchEvent(
      new MouseEvent("mousemove", {
        clientX: 0,
        clientY: yInRow(0, "top") + 20,
        buttons: 0,
        bubbles: true,
      }),
    );

    expect(row1.classList.contains("dragging")).toBe(false);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });
});

// ── reorder arithmetic ─────────────────────────────────────────────────────

describe("reorder index arithmetic", () => {
  it("dropping on the top half inserts before the target", () => {
    signIn("owner");
    // Rows: idx0=ch1, idx1=ch2, idx2=ch3.
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);

    drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]

    expect(rig.onReorder).toHaveBeenCalledTimes(1);
    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 2 });
  });

  it("dropping on the bottom half inserts after the target", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);

    drag(rig, 1, 1, "bottom"); // ch1 after ch2 → [2, 1, 3]

    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
  });

  it("only reports channels whose position actually changed", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2), makeCh(4, 3)]);

    drag(rig, 1, 1, "bottom"); // → [2, 1, 3, 4]; ch3 and ch4 keep their slots

    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    const touched = reorders.map((r) => r.channelId).sort((a, b) => a - b);
    expect(touched).toEqual([1, 2]);
  });

  it("updates the channels store immediately (optimistic reorder)", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    channelsStore.setState(() => ({
      channels: new Map([
        [1, makeCh(1, 0)],
        [2, makeCh(2, 1)],
      ]),
      activeChannelId: null,
      roles: [],
    }));

    drag(rig, 1, 1, "bottom"); // → [2, 1]

    expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
    expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
  });

  it("reassigns the group's own position slots, not a 0-based range, for a category with a non-zero global offset", () => {
    // This category's channels sit at global positions 5, 7, 9 (interleaved
    // with another category's channels at 0/1/2/etc in the server's single
    // global position space). A within-category drag must preserve that
    // range -- renumbering to 0..n-1 would stomp the other category's slots.
    signIn("owner");
    const rig = buildRig([makeCh(1, 5), makeCh(2, 7), makeCh(3, 9)]);

    drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]

    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    expect(positionsOf(reorders)).toEqual({ 3: 5, 1: 7, 2: 9 });
  });

  it("still reorders when every channel in the group shares one position", () => {
    // The server does not enforce unique positions, and newly created
    // channels commonly all sit at position 0. Reassigning the group's own
    // slots must still produce distinct positions, or the drop is a no-op.
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 0)]);

    drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]

    expect(rig.onReorder).toHaveBeenCalledTimes(1);
    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    // ch3 keeps position 0 (unchanged, so unreported); ch1 and ch2 move up.
    expect(positionsOf(reorders)).toEqual({ 1: 1, 2: 2 });
  });

  it("gives partially tied positions a deterministic order after the drop", () => {
    signIn("owner");
    // Two channels tied at 0, one at 5.
    const rig = buildRig([makeCh(1, 0), makeCh(2, 0), makeCh(3, 5)]);

    drag(rig, 3, 0, "top"); // ch3 before ch1 → [3, 1, 2]

    expect(rig.onReorder).toHaveBeenCalledTimes(1);
    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    // Slots [0, 0, 5] become the strictly increasing [0, 1, 5]: every channel
    // ends at a distinct position, so the rendered order matches the drop.
    expect(positionsOf(reorders)).toEqual({ 3: 0, 1: 1, 2: 5 });
  });

  it("does not fire when dropped on itself", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);

    drag(rig, 1, 0, "bottom"); // row 0 is ch1 itself

    expect(rig.onReorder).not.toHaveBeenCalled();
  });

  it("does not fire when dropped outside any row", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const el = rig.items.get(1);

    el?.dispatchEvent(mouse("mousedown", 0, 4));
    el?.dispatchEvent(mouse("mousemove", 0, 30));
    document.dispatchEvent(mouse("mouseup", 0, 9999)); // below every row

    expect(rig.onReorder).not.toHaveBeenCalled();
  });

  it("does not fire when the drag lands back in its original slot", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);

    // ch1 dropped on the top half of ch2 → insert before ch2 → [1, 2, 3],
    // which is the order it already had.
    drag(rig, 1, 1, "top");

    expect(rig.onReorder).not.toHaveBeenCalled();
  });

  it("clears visual state on drop", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);

    drag(rig, 1, 1, "bottom");

    expect(rig.items.get(1)?.classList.contains("dragging")).toBe(false);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
    expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
  });
});

// ── drop indicator ─────────────────────────────────────────────────────────

describe("drop indicator", () => {
  it("marks the hovered row and never the dragged row", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
    const source = rig.items.get(1);

    source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));

    document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
    expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);

    // Hovering the dragged row itself must not show a drop target.
    document.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top")));
    expect(rig.items.get(1)?.classList.contains("channel-drop-indicator")).toBe(false);
    expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(false);
  });

  it("moves the indicator as the cursor moves between rows", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)]);
    const source = rig.items.get(1);

    source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));

    document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
    expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(true);

    document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));
    expect(rig.items.get(2)?.classList.contains("channel-drop-indicator")).toBe(false);
    expect(rig.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
  });

  it("global handlers no-op when no drag is active", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);

    document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top")));

    expect(rig.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
    expect(rig.onReorder).not.toHaveBeenCalled();
  });
});

// ── mid-drag re-render ─────────────────────────────────────────────────────
//
// ChannelSidebar.renderChannels() clears the channel list and rebuilds every
// category group, so any store-driven re-render while the mouse button is
// down detaches the container the drag captured at mousedown. Detached rows
// report all-zero rects, so the drag must re-resolve the live rows or every
// hit-test silently fails.

describe("mid-drag sidebar re-render", () => {
  function setStoreChannels(channels: Channel[]): void {
    channelsStore.setState(() => ({
      channels: new Map(channels.map((c) => [c.id, c])),
      activeChannelId: null,
      roles: [],
    }));
  }

  it("resolves the drop against the live rows after a re-render replaces the container", () => {
    signIn("owner");
    const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
    setStoreChannels(channels);
    const rig = buildRig(channels);
    const source = rig.items.get(1)!;

    // Start the drag on the original rows.
    source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
    expect(source.classList.contains("dragging")).toBe(true);

    // A store-driven re-render rebuilds the sidebar mid-drag.
    rig.container.remove();
    const live = rebuildContainer(rig, channels);

    // Release over the bottom half of row 1 (ch2): ch1 lands after ch2.
    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));

    expect(rig.onReorder).toHaveBeenCalledTimes(1);
    const reorders = rig.onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
    expect(channelsStore.select((s) => s.channels.get(1)?.position)).toBe(1);
    expect(channelsStore.select((s) => s.channels.get(2)?.position)).toBe(0);
    expect(live.container.querySelectorAll(".channel-drop-indicator")).toHaveLength(0);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });

  it("moves the drop indicator to the live rows after a re-render replaces the container", () => {
    signIn("owner");
    const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
    setStoreChannels(channels);
    const rig = buildRig(channels);
    const source = rig.items.get(1)!;

    source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));

    rig.container.remove();
    const live = rebuildContainer(rig, channels);

    document.dispatchEvent(mouse("mousemove", 0, yInRow(2, "top")));

    expect(live.items.get(3)?.classList.contains("channel-drop-indicator")).toBe(true);
  });

  it("discards the drop when the dragged channel has no live row after the re-render", () => {
    signIn("owner");
    const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
    setStoreChannels(channels);
    const rig = buildRig(channels);
    const source = rig.items.get(1)!;

    source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));

    // The re-render drops ch1 entirely (deleted mid-drag).
    rig.container.remove();
    rebuildContainer(rig, [makeCh(2, 1), makeCh(3, 2)]);

    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));

    expect(rig.onReorder).not.toHaveBeenCalled();
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });

  // OC-0296: ChannelSidebar.renderChannels() aborts and replaces a fresh,
  // per-render AbortController on every store-driven re-render, while the
  // sidebar's own lifetime controller (`ac`) is untouched — the two are never
  // the same signal. `rebuildContainer` above re-attaches every row under one
  // constant `rig.abort.signal` across "renders", which models the sidebar's
  // *lifetime* signal, not this per-render one, so it cannot catch a bug that
  // only shows up when the render-scoped signal itself is what gets aborted
  // and replaced mid-drag.
  it("survives a mid-drag re-render that aborts only the render-scoped signal, leaving the sidebar's own lifetime signal untouched", () => {
    signIn("owner");
    const channels = [makeCh(1, 0), makeCh(2, 1), makeCh(3, 2)];
    setStoreChannels(channels);

    // The sidebar's own lifetime controller (ChannelSidebar's `ac`) — never
    // aborted by a re-render, only by the sidebar's own destroy().
    const lifetimeAc = new AbortController();
    rigAborts.push(lifetimeAc);

    let renderAc = new AbortController();
    const container = document.createElement("div");
    document.body.appendChild(container);
    const items = new Map<number, HTMLElement>();
    const onReorder = vi.fn();
    channels.forEach((ch, idx) => {
      const el = document.createElement("div");
      container.appendChild(el);
      stubRowRect(el, idx);
      attachDragHandlers(
        el,
        ch,
        container,
        channels,
        renderAc.signal,
        lifetimeAc.signal,
        onReorder,
      );
      items.set(ch.id, el);
    });

    const source = items.get(1)!;
    source.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
    expect(source.classList.contains("dragging")).toBe(true);

    // A store-driven re-render: renderChannels() aborts ONLY the previous
    // render's controller (never the sidebar's lifetime one) and attaches
    // fresh rows under a brand-new render controller.
    renderAc.abort();
    renderAc = new AbortController();
    container.remove();
    const live = document.createElement("div");
    live.className = "category-channels-container";
    document.body.appendChild(live);
    const liveItems = new Map<number, HTMLElement>();
    channels.forEach((ch, idx) => {
      const el = document.createElement("div");
      live.appendChild(el);
      stubRowRect(el, idx);
      attachDragHandlers(el, ch, live, channels, renderAc.signal, lifetimeAc.signal, onReorder);
      liveItems.set(ch.id, el);
    });

    // Release over the bottom half of row 1 (ch2): ch1 lands after ch2.
    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "bottom")));

    expect(onReorder).toHaveBeenCalledTimes(1);
    const reorders = onReorder.mock.calls[0]?.[0] as readonly ChannelReorderData[];
    expect(positionsOf(reorders)).toEqual({ 2: 0, 1: 1 });
    expect(liveItems.get(1)?.classList.contains("dragging")).toBe(false);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });
});

// ── listener lifecycle ─────────────────────────────────────────────────────
//
// Ownership of the shared document listeners is per sidebar AbortSignal, not
// per attached row. This block used to pin the old per-row ref-count's
// asymmetry as a KNOWN BUG ("N rows take N refs, destroy returns 1, the
// listeners live forever"); that bug is fixed, so the block now pins the
// fixed contract: one owner registration no matter how many rows attach, and
// the owner's abort is the release.

describe("global listener ownership", () => {
  it("attaching many rows under one owner is a single registration; one abort tears down", () => {
    signIn("owner");
    // 2 channels, and a re-attach of the same rows (a sidebar re-render) —
    // under the old ref-count this took 4 refs a single destroy never repaid.
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    for (const [id, el] of rig.items) {
      const ch = rig.channels.find((c) => c.id === id)!;
      attachDragHandlers(
        el,
        ch,
        rig.container,
        rig.channels,
        rig.abort.signal,
        rig.abort.signal,
        rig.onReorder,
      );
    }

    rig.abort.abort();

    // The single owner is gone → listeners torn down; a drag no longer works.
    // (The rig's own element listeners share the aborted signal, so drive the
    // document handlers directly to prove they are dead.)
    document.dispatchEvent(mouse("mousemove", 0, yInRow(1, "top")));
    document.dispatchEvent(mouse("mouseup", 0, yInRow(1, "top")));
    expect(rig.onReorder).not.toHaveBeenCalled();
  });

  it("keeps the listeners alive while another sidebar still owns them", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const other = new AbortController(); // a second sidebar
    ensureGlobalDragListeners(other.signal);

    other.abort(); // the second sidebar goes away

    // The first sidebar still owns the listeners, so its drag must work.
    drag(rig, 1, 1, "bottom");
    expect(rig.onReorder).toHaveBeenCalledTimes(1);
  });

  it("re-registration after full teardown works (a new sidebar after all closed)", () => {
    signIn("owner");
    const first = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    first.abort.abort(); // teardown

    const second = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    drag(second, 1, 1, "bottom");
    expect(second.onReorder).toHaveBeenCalledTimes(1);
  });

  it("aborting the owner mid-drag clears the in-flight visual state", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const source = rig.items.get(1);

    source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));
    expect(source?.classList.contains("dragging")).toBe(true);

    rig.abort.abort();

    // A sidebar destroyed mid-drag must not leave the row stuck in the
    // dragging state or the body stuck in reorder mode.
    expect(source?.classList.contains("dragging")).toBe(false);
    expect(document.body.classList.contains("channel-reordering")).toBe(false);
  });

  it("leaves a drag owned by a different sidebar alone", () => {
    signIn("owner");
    const rig = buildRig([makeCh(1, 0), makeCh(2, 1)]);
    const source = rig.items.get(1);
    const other = new AbortController();
    ensureGlobalDragListeners(other.signal);

    source?.dispatchEvent(mouse("mousedown", 0, yInRow(0, "top")));
    source?.dispatchEvent(mouse("mousemove", 0, yInRow(0, "top") + 20));

    other.abort();

    expect(source?.classList.contains("dragging")).toBe(true);
  });

  it("an already-aborted owner is refused (no dead registration, abort is safe to repeat)", () => {
    const ac = new AbortController();
    ac.abort();
    expect(() => {
      ensureGlobalDragListeners(ac.signal);
      ac.abort();
      ac.abort();
    }).not.toThrow();
  });
});

/** Collapses reorder data into a channelId → newPosition map. */
function positionsOf(reorders: readonly ChannelReorderData[]): Record<number, number> {
  const out: Record<number, number> = {};
  for (const r of reorders) out[r.channelId] = r.newPosition;
  return out;
}
