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

// ---------------------------------------------------------------------------
// Mocks — must be declared before importing VideoGrid
// ---------------------------------------------------------------------------

const mockMuteScreenshareAudio = vi.fn();
const mockSetScreenshareAudioVolume = vi.fn();
const mockSetUserVolume = vi.fn();
const mockGetScreenshareAudioMuted = vi.fn((_userId?: unknown) => false);
const mockGetScreenshareAudioVolume = vi.fn((_userId?: unknown) => 1);
const mockGetUserVolume = vi.fn((_userId?: unknown) => 100);

vi.mock("@lib/livekitSession", () => ({
  muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args),
  setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args),
  setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args),
  getScreenshareAudioMuted: (userId: unknown) => mockGetScreenshareAudioMuted(userId),
  getScreenshareAudioVolume: (userId: unknown) => mockGetScreenshareAudioVolume(userId),
  getUserVolume: (userId: unknown) => mockGetUserVolume(userId),
}));

// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------

import {
  createVideoGrid,
  computeGridLayout,
  type VideoGridComponent,
  type TileConfig,
} from "../../src/components/VideoGrid";

/** Minimal MediaStream stub for testing. */
function fakeStream(): MediaStream {
  return { getTracks: () => [], getVideoTracks: () => [] } as unknown as MediaStream;
}

/**
 * MediaStream stub with a controllable video track.
 * Allows testing track lifecycle (ended/mute events).
 */
function fakeStreamWithTrack(): {
  stream: MediaStream;
  track: { listeners: Record<string, Array<() => void>>; dispatchEvent(type: string): void };
} {
  const listeners: Record<string, Array<() => void>> = {};
  const track = {
    listeners,
    id: `track-${Math.random()}`,
    addEventListener(type: string, fn: () => void) {
      (listeners[type] ??= []).push(fn);
    },
    removeEventListener(type: string, fn: () => void) {
      const arr = listeners[type];
      if (arr) {
        const idx = arr.indexOf(fn);
        if (idx >= 0) arr.splice(idx, 1);
      }
    },
    dispatchEvent(type: string) {
      for (const fn of listeners[type] ?? []) fn();
    },
  };
  const stream = {
    getTracks: () => [track],
    getVideoTracks: () => [track],
  } as unknown as MediaStream;
  return { stream, track };
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeTileConfig(overrides: Partial<TileConfig> = {}): TileConfig {
  return {
    isSelf: false,
    audioUserId: 42,
    isScreenshare: false,
    ...overrides,
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("VideoGrid", () => {
  let container: HTMLDivElement;
  let grid: VideoGridComponent;

  beforeEach(() => {
    vi.clearAllMocks();
    // ResizeObserver is not available in JSDOM
    globalThis.ResizeObserver ??= class {
      observe(): void {
        /* noop */
      }
      unobserve(): void {
        /* noop */
      }
      disconnect(): void {
        /* noop */
      }
    } as unknown as typeof ResizeObserver;

    container = document.createElement("div");
    grid = createVideoGrid();
    grid.mount(container);
  });

  afterEach(() => {
    grid.destroy?.();
  });

  it("mount creates a grid container with data-testid", () => {
    const root = container.querySelector("[data-testid='video-grid']");
    expect(root).not.toBeNull();
    expect(root!.classList.contains("video-grid")).toBe(true);
  });

  it("addStream creates video element and username label", () => {
    grid.addStream(1, "Alice", fakeStream());

    const cell = container.querySelector(".video-cell");
    expect(cell).not.toBeNull();
    expect(cell!.getAttribute("data-user-id")).toBe("1");

    const video = cell!.querySelector("video");
    expect(video).not.toBeNull();
    expect(video!.muted).toBe(true);

    const label = cell!.querySelector(".video-username");
    expect(label).not.toBeNull();
    expect(label!.textContent).toBe("Alice");
  });

  it("addStream replaces existing cell for same userId", () => {
    grid.addStream(1, "Alice", fakeStream());
    grid.addStream(1, "Alice-v2", fakeStream());

    const cells = container.querySelectorAll(".video-cell");
    expect(cells.length).toBe(1);
    expect(cells[0]!.querySelector(".video-username")!.textContent).toBe("Alice-v2");
  });

  it("removeStream removes the cell", () => {
    grid.addStream(1, "Alice", fakeStream());
    expect(container.querySelectorAll(".video-cell").length).toBe(1);

    grid.removeStream(1);
    expect(container.querySelectorAll(".video-cell").length).toBe(0);
  });

  it("removeStream nullifies video srcObject", () => {
    const stream = fakeStream();
    grid.addStream(1, "Alice", stream);

    const video = container.querySelector("video")!;
    expect(video.srcObject).toBe(stream);

    grid.removeStream(1);
    // Video was removed from DOM, but we can verify hasStreams is false
    expect(grid.hasStreams()).toBe(false);
  });

  it("hasStreams returns false when empty", () => {
    expect(grid.hasStreams()).toBe(false);
  });

  it("hasStreams returns true when streams are present", () => {
    grid.addStream(1, "Alice", fakeStream());
    expect(grid.hasStreams()).toBe(true);
  });

  describe("computeGridLayout — Discord-style tile sizing", () => {
    it("returns zero-sized tiles for 0 tile count", () => {
      const layout = computeGridLayout(800, 600, 0);
      expect(layout.tileW).toBe(0);
      expect(layout.tileH).toBe(0);
    });

    it("1 tile fills the container (width-constrained)", () => {
      // Wide container: tile should be width-limited
      const layout = computeGridLayout(800, 600, 1);
      expect(layout.cols).toBe(1);
      expect(layout.rows).toBe(1);
      expect(layout.tileW).toBeGreaterThan(0);
      expect(layout.tileH).toBeGreaterThan(0);
      // Verify 16:9 ratio (within 1px rounding)
      expect(Math.abs(layout.tileW / layout.tileH - 16 / 9)).toBeLessThan(0.1);
    });

    it("1 tile in a tall container is height-constrained", () => {
      // Tall container: tile should be height-limited
      const layout = computeGridLayout(400, 800, 1);
      expect(layout.cols).toBe(1);
      expect(layout.tileH).toBeLessThanOrEqual(800 - 16); // minus padding
    });

    it("2 tiles use 2 columns in a wide container", () => {
      const layout = computeGridLayout(1200, 400, 2);
      expect(layout.cols).toBe(2);
      expect(layout.rows).toBe(1);
    });

    it("4 tiles use 2x2 grid", () => {
      const layout = computeGridLayout(800, 600, 4);
      expect(layout.cols).toBe(2);
      expect(layout.rows).toBe(2);
    });

    it("all tiles fit within the container", () => {
      for (const count of [1, 2, 3, 4, 5, 6, 9, 10, 16]) {
        const layout = computeGridLayout(800, 600, count);
        const totalW = layout.cols * layout.tileW + (layout.cols - 1) * 4 + 16;
        const totalH = layout.rows * layout.tileH + (layout.rows - 1) * 4 + 16;
        expect(totalW).toBeLessThanOrEqual(800);
        expect(totalH).toBeLessThanOrEqual(600);
      }
    });

    it("tiles maintain approximately 16:9 aspect ratio", () => {
      for (const count of [1, 2, 4, 9]) {
        const layout = computeGridLayout(800, 600, count);
        if (layout.tileW === 0) continue;
        const ratio = layout.tileW / layout.tileH;
        expect(Math.abs(ratio - 16 / 9)).toBeLessThan(0.15);
      }
    });
  });

  it("destroy cleans up all elements", () => {
    grid.addStream(1, "Alice", fakeStream());
    grid.addStream(2, "Bob", fakeStream());

    grid.destroy?.();

    expect(container.querySelector(".video-grid")).toBeNull();
    expect(grid.hasStreams()).toBe(false);
  });

  // -----------------------------------------------------------------------
  // Autoplay / .play() tests (Bug fix: black window in WebView2)
  // -----------------------------------------------------------------------

  describe("video autoplay", () => {
    it("addStream calls .play() on the video element", () => {
      const playMock = vi.fn().mockResolvedValue(undefined);
      const origCreate = document.createElement.bind(document);
      vi.spyOn(document, "createElement").mockImplementation(
        (tag: string, opts?: ElementCreationOptions) => {
          const el = origCreate(tag, opts);
          if (tag === "video") {
            (el as HTMLVideoElement).play = playMock;
          }
          return el;
        },
      );

      grid.addStream(1, "Alice", fakeStream());

      expect(playMock).toHaveBeenCalledTimes(1);

      vi.restoreAllMocks();
    });

    it("addStream calls .play() when replacing srcObject on existing tile", () => {
      const playMock = vi.fn().mockResolvedValue(undefined);
      const origCreate = document.createElement.bind(document);
      vi.spyOn(document, "createElement").mockImplementation(
        (tag: string, opts?: ElementCreationOptions) => {
          const el = origCreate(tag, opts);
          if (tag === "video") {
            (el as HTMLVideoElement).play = playMock;
          }
          return el;
        },
      );

      const { stream: s1 } = fakeStreamWithTrack();
      const { stream: s2 } = fakeStreamWithTrack();

      grid.addStream(1, "Alice", s1);
      playMock.mockClear();

      // Different tracks → srcObject replaced → should call play again
      grid.addStream(1, "Alice", s2);
      expect(playMock).toHaveBeenCalledTimes(1);

      vi.restoreAllMocks();
    });
  });

  // -----------------------------------------------------------------------
  // Track lifecycle tests (Bug fix: stale black tiles)
  // -----------------------------------------------------------------------

  describe("track lifecycle", () => {
    it("removes tile when video track fires 'ended' event", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);
      expect(grid.hasStreams()).toBe(true);

      track.dispatchEvent("ended");
      expect(grid.hasStreams()).toBe(false);
    });

    it("hides tile when video track fires 'mute' event (does not remove)", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);
      expect(grid.hasStreams()).toBe(true);

      track.dispatchEvent("mute");
      // Tile should still exist but have track-muted class
      expect(grid.hasStreams()).toBe(true);
      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.classList.contains("track-muted")).toBe(true);
    });

    it("restores tile when video track fires 'unmute' after mute", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);

      track.dispatchEvent("mute");
      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.classList.contains("track-muted")).toBe(true);

      track.dispatchEvent("unmute");
      expect(cell.classList.contains("track-muted")).toBe(false);
    });

    it("cleans up track listeners when tile is removed via removeStream", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);
      grid.removeStream(1);

      // Dispatching ended after removal should not throw or cause issues
      expect(() => track.dispatchEvent("ended")).not.toThrow();
      expect(grid.hasStreams()).toBe(false);
    });

    it("cleans up track listeners on destroy", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);
      grid.destroy!();

      // Verify listeners were removed
      expect(track.listeners["ended"]?.length ?? 0).toBe(0);
      expect(track.listeners["mute"]?.length ?? 0).toBe(0);
      expect(track.listeners["unmute"]?.length ?? 0).toBe(0);
    });
  });

  // -----------------------------------------------------------------------
  // TileConfig / overlay / mute button tests (Spec 1)
  // -----------------------------------------------------------------------

  describe("tile overlay and audio controls", () => {
    it("addStream with isSelf=true does NOT render overlay", () => {
      const config = makeTileConfig({ isSelf: true });
      grid.addStream(1, "me (You)", fakeStream(), config);

      expect(container.querySelector(".video-tile-overlay")).toBeNull();
    });

    it("addStream with isSelf=false renders overlay and mute button", () => {
      const config = makeTileConfig({ isSelf: false });
      grid.addStream(42, "alice", fakeStream(), config);

      expect(container.querySelector(".video-tile-overlay")).not.toBeNull();
      expect(container.querySelector(".tile-mute-btn")).not.toBeNull();
    });

    it("mute button toggles screenshare audio when isScreenshare=true", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 99, isScreenshare: true });
      grid.addStream(99, "bob (Screen)", fakeStream(), config);

      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;

      muteBtn.click();
      expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(99, true);

      muteBtn.click();
      expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(99, false);
    });

    it("mute button toggles mic audio when isScreenshare=false", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 55, isScreenshare: false });
      grid.addStream(55, "charlie", fakeStream(), config);

      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;

      muteBtn.click();
      expect(mockSetUserVolume).toHaveBeenCalledWith(55, 0);

      muteBtn.click();
      expect(mockSetUserVolume).toHaveBeenCalledWith(55, 100);
    });

    it("mute button icon swaps between volume and volume-x SVGs on click", () => {
      const config = makeTileConfig({ isSelf: false });
      grid.addStream(42, "alice", fakeStream(), config);

      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
      const initialHtml = muteBtn.innerHTML;

      // Volume icon has polygon but no <line> elements
      expect(initialHtml).toContain("polygon");
      expect(initialHtml).not.toContain("<line");

      // Click to mute — should swap to volume-x icon with <line> elements
      muteBtn.click();
      expect(muteBtn.innerHTML).toContain("<line");

      // Click to unmute — should swap back to volume icon
      muteBtn.click();
      expect(muteBtn.innerHTML).not.toContain("<line");
    });

    it("mute button aria-label updates between Mute and Unmute", () => {
      const config = makeTileConfig({ isSelf: false });
      grid.addStream(42, "alice", fakeStream(), config);

      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;

      expect(muteBtn.getAttribute("aria-label")).toBe("Mute");

      muteBtn.click();
      expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");

      muteBtn.click();
      expect(muteBtn.getAttribute("aria-label")).toBe("Mute");
    });

    it("addStream without config (backward compat) renders no overlay", () => {
      grid.addStream(42, "alice", fakeStream());

      const cell = container.querySelector(".video-cell");
      expect(cell).not.toBeNull();
      expect(container.querySelector(".video-tile-overlay")).toBeNull();
    });

    it("volume slider adjusts user volume for non-screenshare tiles", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
      grid.addStream(77, "dave", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      expect(slider).not.toBeNull();
      expect(slider.value).toBe("100"); // default

      // Slide to 50
      slider.value = "50";
      slider.dispatchEvent(new Event("input"));
      expect(mockSetUserVolume).toHaveBeenCalledWith(77, 50);
    });

    it("[B3-5] seeds the mic-tile slider from the persisted per-user volume, not a hardcoded 100%", () => {
      mockGetUserVolume.mockReturnValueOnce(30);
      const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: false });
      grid.addStream(88, "erin", fakeStream(), config);

      expect(mockGetUserVolume).toHaveBeenCalledWith(88);
      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      expect(slider.value).toBe("30");
      // Not muted at 30% — the mute button must reflect the real (unmuted) state.
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
      expect(muteBtn.getAttribute("aria-label")).toBe("Mute");
    });

    it("[B3-5] starts a mic tile muted when the persisted per-user volume is 0", () => {
      mockGetUserVolume.mockReturnValueOnce(0);
      const config = makeTileConfig({ isSelf: false, audioUserId: 89, isScreenshare: false });
      grid.addStream(89, "frank", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      expect(slider.value).toBe("0");
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
      expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
      const overlay = container.querySelector(".video-tile-overlay");
      expect(overlay!.classList.contains("muted")).toBe(true);
    });

    it("volume slider at 0 triggers mute icon swap and calls setUserVolume(0)", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
      grid.addStream(77, "dave", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;

      // Slide to 0
      slider.value = "0";
      slider.dispatchEvent(new Event("input"));

      expect(mockSetUserVolume).toHaveBeenCalledWith(77, 0);
      // Mute icon should change
      expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");

      // Overlay should have muted class
      const overlay = container.querySelector(".video-tile-overlay");
      expect(overlay!.classList.contains("muted")).toBe(true);
    });

    it("volume slider for screenshare tiles calls muteScreenshareAudio", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
      grid.addStream(88, "screen", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;

      // Slide to 0 — should mute screenshare
      slider.value = "0";
      slider.dispatchEvent(new Event("input"));
      expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(88, true);

      // Slide to 100 — should unmute screenshare
      slider.value = "100";
      slider.dispatchEvent(new Event("input"));
      expect(mockMuteScreenshareAudio).toHaveBeenCalledWith(88, false);
    });

    it("screenshare slider uses 0-100 range where 100 maps to volume 1.0", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
      grid.addStream(88, "screen", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      expect(slider.max).toBe("100");
      expect(slider.value).toBe("100"); // stored default 1.0 → 100

      slider.value = "50";
      slider.dispatchEvent(new Event("input"));
      expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 0.5);

      slider.value = "100";
      slider.dispatchEvent(new Event("input"));
      expect(mockSetScreenshareAudioVolume).toHaveBeenCalledWith(88, 1);
    });

    it("screenshare slider initializes from stored volume and mute state", () => {
      mockGetScreenshareAudioVolume.mockReturnValueOnce(0.3);
      mockGetScreenshareAudioMuted.mockReturnValueOnce(true);
      const config = makeTileConfig({ isSelf: false, audioUserId: 88, isScreenshare: true });
      grid.addStream(88, "screen", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
      const overlay = container.querySelector(".video-tile-overlay");
      expect(slider.value).toBe("30");
      expect(muteBtn.getAttribute("aria-label")).toBe("Unmute");
      expect(overlay!.classList.contains("muted")).toBe(true);
    });

    it("mic slider keeps the 0-200 boost range", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
      grid.addStream(77, "dave", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      expect(slider.max).toBe("200");
    });

    it("mute button unmutes with previous volume when currentVolume was non-zero", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 77, isScreenshare: false });
      grid.addStream(77, "dave", fakeStream(), config);

      const slider = container.querySelector(".tile-volume-slider") as HTMLInputElement;
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;

      // Set volume to 150 via slider
      slider.value = "150";
      slider.dispatchEvent(new Event("input"));
      mockSetUserVolume.mockClear();

      // Mute via button
      muteBtn.click();
      expect(mockSetUserVolume).toHaveBeenCalledWith(77, 0);
      expect(slider.value).toBe("0");

      // Unmute via button — should restore to 150
      muteBtn.click();
      expect(mockSetUserVolume).toHaveBeenCalledWith(77, 150);
      expect(slider.value).toBe("150");
    });
  });

  // -----------------------------------------------------------------------
  // Stream type attribute tests (Spec 3 — video scaling)
  // -----------------------------------------------------------------------

  describe("data-stream-type attribute", () => {
    it("sets data-stream-type='screenshare' when isScreenshare is true", () => {
      const config = makeTileConfig({ isScreenshare: true });
      grid.addStream(1, "Alice (Screen)", fakeStream(), config);

      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.dataset.streamType).toBe("screenshare");
    });

    it("sets data-stream-type='camera' when isScreenshare is false", () => {
      const config = makeTileConfig({ isScreenshare: false });
      grid.addStream(1, "Alice", fakeStream(), config);

      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.dataset.streamType).toBe("camera");
    });

    it("defaults data-stream-type='camera' when no config is provided", () => {
      grid.addStream(1, "Alice", fakeStream());

      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.dataset.streamType).toBe("camera");
    });

    it("preserves data-stream-type when stream is updated in-place", () => {
      const config = makeTileConfig({ isScreenshare: true });
      grid.addStream(1, "Alice (Screen)", fakeStream(), config);

      // Update stream (same userId)
      grid.addStream(1, "Alice (Screen) v2", fakeStream(), config);

      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.dataset.streamType).toBe("screenshare");
    });

    it("syncs data-stream-type when stream type changes on in-place update", () => {
      const cameraConfig = makeTileConfig({ isScreenshare: false });
      grid.addStream(1, "Alice", fakeStream(), cameraConfig);

      const cell = container.querySelector(".video-cell") as HTMLElement;
      expect(cell.dataset.streamType).toBe("camera");

      // Re-add same userId as screenshare
      const screenConfig = makeTileConfig({ isScreenshare: true });
      grid.addStream(1, "Alice (Screen)", fakeStream(), screenConfig);

      expect(cell.dataset.streamType).toBe("screenshare");
    });
  });

  // -----------------------------------------------------------------------
  // Focus mode tests (Spec 2)
  // -----------------------------------------------------------------------

  describe("focus mode", () => {
    it("setFocusedTile creates focus layout with main and strip areas", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);

      const mainArea = container.querySelector(".video-focus-main");
      const stripArea = container.querySelector(".video-focus-strip");
      expect(mainArea).not.toBeNull();
      expect(stripArea).not.toBeNull();

      // Focused tile should be in main area
      const focusedCell = mainArea!.querySelector('[data-user-id="1"]');
      expect(focusedCell).not.toBeNull();
      expect(focusedCell!.classList.contains("focused")).toBe(true);

      // Other tile should be in strip area
      const thumbCell = stripArea!.querySelector('[data-user-id="2"]');
      expect(thumbCell).not.toBeNull();
      expect(thumbCell!.classList.contains("thumb")).toBe(true);
    });

    it("clicking a thumbnail switches focus", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);

      // Click the second tile (thumbnail in strip)
      const thumbCell = container.querySelector('[data-user-id="2"]') as HTMLElement;
      expect(thumbCell).not.toBeNull();
      thumbCell.click();

      // Now tile 2 should be focused in main area
      const mainArea = container.querySelector(".video-focus-main");
      expect(mainArea).not.toBeNull();
      const newFocused = mainArea!.querySelector('[data-user-id="2"]');
      expect(newFocused).not.toBeNull();
      expect(newFocused!.classList.contains("focused")).toBe(true);

      // Tile 1 should now be a thumbnail
      const stripArea = container.querySelector(".video-focus-strip");
      expect(stripArea).not.toBeNull();
      const oldFocused = stripArea!.querySelector('[data-user-id="1"]');
      expect(oldFocused).not.toBeNull();
      expect(oldFocused!.classList.contains("thumb")).toBe(true);
    });

    it("removeStream auto-focuses next tile when focused tile is removed", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);
      expect(grid.getFocusedTileId()).toBe(1);

      grid.removeStream(1);

      // Remaining tile 2 should become focused
      expect(grid.getFocusedTileId()).toBe(2);
    });

    it("removeStream clears focus when last tile removed", () => {
      grid.addStream(1, "Alice", fakeStream());

      grid.setFocusedTile(1);
      expect(grid.getFocusedTileId()).toBe(1);

      grid.removeStream(1);

      // Focus cleared — no focus-mode class
      expect(grid.getFocusedTileId()).toBeNull();
      const root = container.querySelector(".video-grid");
      expect(root!.classList.contains("focus-mode")).toBe(false);
    });

    it("getFocusedTileId returns correct value", () => {
      grid.addStream(1, "Alice", fakeStream());

      expect(grid.getFocusedTileId()).toBeNull();

      grid.setFocusedTile(1);
      expect(grid.getFocusedTileId()).toBe(1);
    });

    it("focus-mode class is added to root when a tile is focused", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());

      const root = container.querySelector(".video-grid") as HTMLElement;
      expect(root.classList.contains("focus-mode")).toBe(false);

      grid.setFocusedTile(1);
      expect(root.classList.contains("focus-mode")).toBe(true);
    });

    it("strip area not shown when only one tile is focused (no thumbnails)", () => {
      grid.addStream(1, "Alice", fakeStream());

      grid.setFocusedTile(1);

      const mainArea = container.querySelector(".video-focus-main");
      expect(mainArea).not.toBeNull();
      // Only one tile — no strip should be rendered
      const stripArea = container.querySelector(".video-focus-strip");
      expect(stripArea).toBeNull();
    });

    it("clicking mute button on a focused tile does not switch focus", () => {
      const config = makeTileConfig({ isSelf: false, audioUserId: 1, isScreenshare: false });
      grid.addStream(1, "Alice", fakeStream(), config);
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);
      expect(grid.getFocusedTileId()).toBe(1);

      // Click the mute button on Alice's tile
      const muteBtn = container.querySelector(".tile-mute-btn") as HTMLButtonElement;
      muteBtn.click();

      // Focus should remain on tile 1
      expect(grid.getFocusedTileId()).toBe(1);
    });

    it("adding a stream during focus mode preserves focus layout", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);

      // Add a third stream
      grid.addStream(3, "Charlie", fakeStream());

      // Focus should still be on tile 1
      expect(grid.getFocusedTileId()).toBe(1);

      const mainArea = container.querySelector(".video-focus-main");
      expect(mainArea).not.toBeNull();
      expect(mainArea!.querySelector('[data-user-id="1"]')).not.toBeNull();

      // Both Bob and Charlie should be in strip
      const stripArea = container.querySelector(".video-focus-strip");
      expect(stripArea).not.toBeNull();
      expect(stripArea!.querySelectorAll(".video-cell").length).toBe(2);
    });

    it("focused cell retains data-stream-type attribute in focus layout", () => {
      const config = makeTileConfig({ isScreenshare: true });
      grid.addStream(1, "Alice (Screen)", fakeStream(), config);
      grid.addStream(2, "Bob", fakeStream());

      grid.setFocusedTile(1);

      const mainArea = container.querySelector(".video-focus-main");
      const focusedCell = mainArea!.querySelector(".video-cell") as HTMLElement;
      expect(focusedCell.dataset.streamType).toBe("screenshare");
    });

    it("thumbnail cells retain data-stream-type attribute in strip", () => {
      const cameraConfig = makeTileConfig({ isScreenshare: false });
      grid.addStream(1, "Alice", fakeStream(), cameraConfig);

      const screenConfig = makeTileConfig({ isScreenshare: true });
      grid.addStream(2, "Bob (Screen)", fakeStream(), screenConfig);

      grid.setFocusedTile(2);

      const stripArea = container.querySelector(".video-focus-strip");
      const thumbCell = stripArea!.querySelector(".video-cell") as HTMLElement;
      expect(thumbCell.dataset.streamType).toBe("camera");
    });

    it("removing non-focused tile preserves current focus", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());
      grid.addStream(3, "Charlie", fakeStream());

      grid.setFocusedTile(1);
      grid.removeStream(3);

      expect(grid.getFocusedTileId()).toBe(1);
      const mainArea = container.querySelector(".video-focus-main");
      expect(mainArea!.querySelector('[data-user-id="1"]')).not.toBeNull();
    });
  });

  // -----------------------------------------------------------------------
  // clearStreams (B1-8: stale remote tiles survive join -> leave -> join)
  // -----------------------------------------------------------------------

  describe("clearStreams", () => {
    it("removes every tile", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.addStream(2, "Bob", fakeStream());
      expect(grid.hasStreams()).toBe(true);

      grid.clearStreams();

      expect(grid.hasStreams()).toBe(false);
      expect(container.querySelectorAll(".video-cell").length).toBe(0);
    });

    it("clears focus state along with the tiles", () => {
      grid.addStream(1, "Alice", fakeStream());
      grid.setFocusedTile(1);
      expect(grid.getFocusedTileId()).toBe(1);

      grid.clearStreams();

      expect(grid.getFocusedTileId()).toBeNull();
    });

    it("cleans up track listeners for every cell", () => {
      const { stream, track } = fakeStreamWithTrack();
      grid.addStream(1, "Alice", stream);

      grid.clearStreams();

      expect(track.listeners["ended"]?.length ?? 0).toBe(0);
    });

    it("is a no-op on an empty grid", () => {
      expect(() => grid.clearStreams()).not.toThrow();
      expect(grid.hasStreams()).toBe(false);
    });
  });
});
