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

const fetchImageAsDataUrl = vi.hoisted(() => vi.fn());

// Only the network fetch is stubbed -- isSafeUrl/resolveServerUrl are
// reimplemented (not mocked away) so the raw-src-vs-authenticated-fetch
// distinction this suite exercises stays honest. Mirrors tests/unit/avatar.test.ts.
vi.mock("@components/message-list/attachments", () => ({
  fetchImageAsDataUrl,
  isSafeUrl: (url: string) => url.startsWith("https://") || url.startsWith("http://"),
  resolveServerUrl: (url: string) => (url.startsWith("http") ? url : `https://server.test${url}`),
}));

import { createDmSidebar } from "../../src/components/DmSidebar";
import type { DmConversation } from "../../src/components/DmSidebar";

const makeConvo = (overrides: Partial<DmConversation> = {}): DmConversation => ({
  channelId: 100,
  userId: 1,
  username: "Alice",
  avatar: null,
  status: "online",
  lastMessage: "Hello!",
  timestamp: "2025-01-01T00:00:00Z",
  unread: false,
  ...overrides,
});

describe("DmSidebar", () => {
  let container: HTMLDivElement;

  beforeEach(() => {
    fetchImageAsDataUrl.mockReset();
    container = document.createElement("div");
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  it("renders the sidebar with search input", () => {
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const searchInput = container.querySelector(".dm-search");
    expect(searchInput).not.toBeNull();
    expect((searchInput as HTMLInputElement).placeholder).toBe("Find a conversation");

    sidebar.destroy?.();
  });

  it("filters the conversation list as the search box is typed into", () => {
    const sidebar = createDmSidebar({
      conversations: [
        makeConvo({ channelId: 1, username: "Alice" }),
        makeConvo({ channelId: 2, username: "Bob" }),
      ],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const searchInput = container.querySelector(".dm-search") as HTMLInputElement;
    searchInput.value = "ali";
    searchInput.dispatchEvent(new Event("input", { bubbles: true }));

    const visibleNames = [...container.querySelectorAll(".dm-item")]
      .filter((el) => (el as HTMLElement).style.display !== "none")
      .map((el) => el.querySelector(".dm-name")?.textContent);
    expect(visibleNames).toEqual(["Alice"]);

    // Clearing the query shows every conversation again.
    searchInput.value = "";
    searchInput.dispatchEvent(new Event("input", { bubbles: true }));
    const allVisible = [...container.querySelectorAll(".dm-item")].every(
      (el) => (el as HTMLElement).style.display !== "none",
    );
    expect(allVisible).toBe(true);

    sidebar.destroy?.();
  });

  // The Friends nav item was removed in phase 6: it was a dead entry whose
  // callback was never wired, and the plan's stated option was to delete it
  // rather than build a friends list. This pins the deletion.
  it("does not render a Friends nav item", () => {
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    expect(container.querySelector(".dm-nav-item")).toBeNull();
    expect(container.textContent).not.toContain("Friends");

    sidebar.destroy?.();
  });

  it("renders conversation items", () => {
    const conversations: DmConversation[] = [
      makeConvo({ userId: 1, username: "Alice" }),
      makeConvo({ userId: 2, username: "Bob" }),
    ];

    const sidebar = createDmSidebar({
      conversations,
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const items = container.querySelectorAll(".dm-item");
    expect(items.length).toBe(2);

    sidebar.destroy?.();
  });

  it("sorts unread conversations first", () => {
    const conversations: DmConversation[] = [
      makeConvo({ userId: 1, username: "Alice", unread: false }),
      makeConvo({ userId: 2, username: "Bob", unread: true }),
    ];

    const sidebar = createDmSidebar({
      conversations,
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const items = container.querySelectorAll(".dm-item");
    // Bob (unread) should come first
    expect(items[0]!.querySelector(".dm-name")!.textContent).toBe("Bob");
    expect(items[1]!.querySelector(".dm-name")!.textContent).toBe("Alice");

    sidebar.destroy?.();
  });

  it("shows unread dot for unread conversations", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ unread: true })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const unreadDot = container.querySelector(".dm-unread");
    expect(unreadDot).not.toBeNull();

    sidebar.destroy?.();
  });

  it("calls onSelectConversation when a DM item is clicked", () => {
    const onSelectConversation = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ channelId: 42, userId: 42 })],
      onSelectConversation,
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const item = container.querySelector(".dm-item") as HTMLElement;
    item.click();
    expect(onSelectConversation).toHaveBeenCalledWith(42);

    sidebar.destroy?.();
  });

  it("calls onCloseDm when close button is clicked", () => {
    const onCloseDm = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ channelId: 42, userId: 42 })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
      onCloseDm,
    });
    sidebar.mount(container);

    const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement;
    closeBtn.click();
    expect(onCloseDm).toHaveBeenCalledWith(42);

    sidebar.destroy?.();
  });

  it("calls onNewDm when add button is clicked", () => {
    const onNewDm = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm,
    });
    sidebar.mount(container);

    const addBtn = container.querySelector(".dm-add") as HTMLButtonElement;
    addBtn.click();
    expect(onNewDm).toHaveBeenCalledOnce();

    sidebar.destroy?.();
  });

  it("shows avatar initial when no avatar image", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ username: "alice", avatar: null })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const avatar = container.querySelector(".dm-avatar");
    expect(avatar!.textContent).toBe("A");

    sidebar.destroy?.();
  });

  it("shows avatar image when avatar URL is provided", async () => {
    // <img src> cannot carry the bearer token an authenticated file route
    // needs, so the picture is fetched through the same cert-pinned path
    // attachments use and swapped in once the bytes arrive -- never assigned
    // as a raw src.
    fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,AAA");
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ avatar: "http://example.com/img.png" })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    expect(fetchImageAsDataUrl).toHaveBeenCalledWith("http://example.com/img.png");
    await vi.waitFor(() => {
      const img = container.querySelector(".dm-avatar img") as HTMLImageElement;
      expect(img).not.toBeNull();
      expect(img.src).toBe("data:image/png;base64,AAA");
    });

    sidebar.destroy?.();
  });

  it("fetches a server-relative avatar through the authenticated path instead of leaving a raw <img src> that 404s", async () => {
    fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,BBB");
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ username: "Bob", avatar: "/api/v1/files/42" })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    // The letter fallback draws immediately, not skipped in favor of a
    // broken <img>.
    const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
    expect(avatar.textContent).toBe("B");

    await vi.waitFor(() => {
      expect(fetchImageAsDataUrl).toHaveBeenCalledWith("https://server.test/api/v1/files/42");
      const img = avatar.querySelector("img");
      expect(img).not.toBeNull();
      expect(img?.getAttribute("src")).toBe("data:image/png;base64,BBB");
    });

    sidebar.destroy?.();
  });

  it("keeps the presence dot when the fetched avatar image is swapped in", async () => {
    fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,CCC");
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ avatar: "/api/v1/files/42", status: "online" })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
    // Dot and letter are both there before the bytes arrive.
    expect(avatar.querySelector(".dm-status")).not.toBeNull();
    expect(avatar.textContent).toBe("A");

    await vi.waitFor(() => {
      expect(avatar.querySelector("img")).not.toBeNull();
    });

    // The image replaces the letter only — presence survives the swap.
    const dot = avatar.querySelector(".dm-status") as HTMLSpanElement;
    expect(dot).not.toBeNull();
    expect(dot.style.background).toBe("var(--green)");
    expect(avatar.textContent).toBe("");

    sidebar.destroy?.();
  });

  it("swaps fetched images into group faces, keeping the letter for members without one", async () => {
    fetchImageAsDataUrl.mockResolvedValue("data:image/png;base64,DDD");
    const sidebar = createDmSidebar({
      conversations: [
        makeConvo({
          channelId: 9,
          isGroup: true,
          username: "Weekend Plans",
          participants: [
            { id: 2, username: "Bob", avatar: "/api/v1/files/7" },
            { id: 3, username: "Carol", avatar: null },
          ],
        }),
      ],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const faces = container.querySelectorAll(".dm-avatar-face");
    expect(faces.length).toBe(2);
    expect(faces[0]!.textContent).toBe("B");

    await vi.waitFor(() => {
      expect(faces[0]!.querySelector("img")).not.toBeNull();
    });
    expect(faces[0]!.textContent).toBe("");
    // Carol has no avatar: her letter stays and nothing was fetched for her.
    expect(faces[1]!.textContent).toBe("C");
    expect(faces[1]!.querySelector("img")).toBeNull();
    expect(fetchImageAsDataUrl).toHaveBeenCalledTimes(1);

    sidebar.destroy?.();
  });

  it("marks active conversation with active class", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ active: true })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const item = container.querySelector(".dm-item");
    expect(item!.classList.contains("active")).toBe(true);

    sidebar.destroy?.();
  });

  it("cleans up on destroy", () => {
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    expect(container.querySelector(".channel-sidebar")).not.toBeNull();

    sidebar.destroy?.();
    expect(container.querySelector(".channel-sidebar")).toBeNull();
  });

  it("renders back header when onBack is provided", () => {
    const onBack = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
      onBack,
      serverName: "OwnCord",
    });
    sidebar.mount(container);

    const backHeader = container.querySelector('[data-testid="dm-back-header"]');
    expect(backHeader).not.toBeNull();
    expect(backHeader!.textContent).toContain("Back to OwnCord");
    expect(backHeader!.textContent).toContain("Return to channels");

    sidebar.destroy?.();
  });

  it("clicking back header calls onBack", () => {
    const onBack = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
      onBack,
    });
    sidebar.mount(container);

    const backHeader = container.querySelector('[data-testid="dm-back-header"]') as HTMLDivElement;
    backHeader.click();
    expect(onBack).toHaveBeenCalledOnce();

    sidebar.destroy?.();
  });

  it("does not render back header when onBack is undefined", () => {
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const backHeader = container.querySelector('[data-testid="dm-back-header"]');
    expect(backHeader).toBeNull();

    sidebar.destroy?.();
  });

  it("applies correct status color to DM status dot", () => {
    const sidebar = createDmSidebar({
      conversations: [
        makeConvo({ userId: 1, username: "Alice", status: "online" }),
        makeConvo({ userId: 2, username: "Bob", status: "dnd" }),
        makeConvo({ userId: 3, username: "Charlie", status: "idle" }),
        makeConvo({ userId: 4, username: "Dave", status: "offline" }),
      ],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const statusDots = container.querySelectorAll(".dm-status") as NodeListOf<HTMLSpanElement>;
    const colors = Array.from(statusDots).map((dot) => dot.style.background);

    expect(colors).toContain("var(--green)");
    expect(colors).toContain("var(--red)");
    expect(colors).toContain("var(--yellow)");
    expect(colors).toContain("var(--text-micro)");

    sidebar.destroy?.();
  });

  it("close button click does not trigger conversation select", () => {
    const onSelectConversation = vi.fn();
    const onCloseDm = vi.fn();
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ channelId: 42, userId: 42 })],
      onSelectConversation,
      onNewDm: vi.fn(),
      onCloseDm,
    });
    sidebar.mount(container);

    const closeBtn = container.querySelector(".dm-close") as HTMLButtonElement;
    closeBtn.click();

    // Close fires but select should NOT fire (stopPropagation)
    expect(onCloseDm).toHaveBeenCalledWith(42);
    expect(onSelectConversation).not.toHaveBeenCalled();

    sidebar.destroy?.();
  });

  it("clicking a DM item marks it active and deactivates siblings", () => {
    const sidebar = createDmSidebar({
      conversations: [
        makeConvo({ userId: 1, username: "Alice" }),
        makeConvo({ userId: 2, username: "Bob" }),
      ],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const items = container.querySelectorAll(".dm-item");
    (items[0] as HTMLElement).click();
    expect(items[0]!.classList.contains("active")).toBe(true);

    // Click the second item — first should lose active
    (items[1] as HTMLElement).click();
    expect(items[1]!.classList.contains("active")).toBe(true);
    expect(items[0]!.classList.contains("active")).toBe(false);

    sidebar.destroy?.();
  });

  it("uses default avatar color when avatarColor is not provided", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ avatar: null, avatarColor: undefined })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
    // Default background is #5865F2
    expect(avatar.style.background).toBe("rgb(88, 101, 242)");

    sidebar.destroy?.();
  });

  it("uses custom avatar color when provided", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ avatar: null, avatarColor: "#ff0000" })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const avatar = container.querySelector(".dm-avatar") as HTMLDivElement;
    expect(avatar.style.background).toBe("rgb(255, 0, 0)");

    sidebar.destroy?.();
  });

  it("does not show unread dot for read conversations", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ unread: false })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const unreadDot = container.querySelector(".dm-unread");
    expect(unreadDot).toBeNull();

    sidebar.destroy?.();
  });

  it("back header defaults to 'Server' when serverName not provided", () => {
    const sidebar = createDmSidebar({
      conversations: [],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
      onBack: vi.fn(),
    });
    sidebar.mount(container);

    const backHeader = container.querySelector('[data-testid="dm-back-header"]');
    expect(backHeader!.textContent).toContain("Back to Server");

    sidebar.destroy?.();
  });

  it("uses offline status color for unknown status values", () => {
    const sidebar = createDmSidebar({
      conversations: [makeConvo({ status: undefined })],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);

    const statusDot = container.querySelector(".dm-status") as HTMLSpanElement;
    // undefined status falls back to "offline" key
    expect(statusDot.style.background).toBe("var(--text-micro)");

    sidebar.destroy?.();
  });
});

// ── Unread / mention badges ────────────────────────────────────────────────
//
// A DM used to show only a dot, which said "something happened" but not how
// much — and never distinguished a mention from ordinary traffic.

describe("DmSidebar — unread and mention badges", () => {
  let container: HTMLDivElement;

  beforeEach(() => {
    container = document.createElement("div");
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  function mountWith(convo: DmConversation): void {
    const sidebar = createDmSidebar({
      conversations: [convo],
      onSelectConversation: vi.fn(),
      onNewDm: vi.fn(),
    });
    sidebar.mount(container);
  }

  it("renders the unread count, not a bare dot", () => {
    mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 4 }));

    const badge = container.querySelector('[data-testid="dm-unread-7"]');
    expect(badge?.textContent).toBe("4");
    expect(container.querySelector(".dm-unread")).toBeNull();
  });

  it("renders a mention badge instead of the unread badge", () => {
    mountWith(
      makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 5, mentionCount: 2 }),
    );

    expect(container.querySelector('[data-testid="dm-mentions-7"]')?.textContent).toBe("2");
    expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
  });

  it("pluralises the badge tooltips", () => {
    mountWith(makeConvo({ channelId: 7, userId: 7, unread: true, unreadCount: 1 }));
    expect((container.querySelector('[data-testid="dm-unread-7"]') as HTMLElement).title).toBe(
      "1 unread message",
    );
  });

  it("falls back to the dot when the payload carries no counts", () => {
    mountWith(makeConvo({ channelId: 7, userId: 7, unread: true }));

    expect(container.querySelector(".dm-unread")).not.toBeNull();
    expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
  });

  it("renders no badge and no dot for a read conversation", () => {
    mountWith(
      makeConvo({ channelId: 7, userId: 7, unread: false, unreadCount: 0, mentionCount: 0 }),
    );

    expect(container.querySelector(".dm-unread")).toBeNull();
    expect(container.querySelector('[data-testid="dm-unread-7"]')).toBeNull();
    expect(container.querySelector('[data-testid="dm-mentions-7"]')).toBeNull();
  });
});
