import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { Sidebar } from '../components/Sidebar';
import {
  CONTACT_TYPE_REPEATER,
  CONTACT_TYPE_ROOM,
  type Channel,
  type Contact,
  type Favorite,
} from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';

function makeChannel(key: string, name: string): Channel {
  return {
    key,
    name,
    is_hashtag: false,
    on_radio: false,
    last_read_at: null,
  };
}

function makeContact(
  public_key: string,
  name: string,
  type = 1,
  overrides: Partial<Contact> = {}
): Contact {
  return {
    public_key,
    name,
    type,
    flags: 0,
    direct_path: null,
    direct_path_len: -1,
    direct_path_hash_mode: 0,
    last_advert: null,
    lat: null,
    lon: null,
    last_seen: null,
    on_radio: false,
    last_contacted: null,
    last_read_at: null,
    first_seen: null,
    ...overrides,
  };
}

function renderSidebar(overrides?: {
  unreadCounts?: Record<string, number>;
  mentions?: Record<string, boolean>;
  favorites?: Favorite[];
  lastMessageTimes?: ConversationTimes;
  channels?: Channel[];
  isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) {
  const aliceName = 'Alice';
  const roomName = 'Ops Board';
  const publicChannel = makeChannel('AA'.repeat(16), 'Public');
  const flightChannel = makeChannel('BB'.repeat(16), '#flight');
  const opsChannel = makeChannel('CC'.repeat(16), '#ops');
  const alice = makeContact('11'.repeat(32), aliceName);
  const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
  const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);

  const unreadCounts = overrides?.unreadCounts ?? {
    [getStateKey('channel', flightChannel.key)]: 2,
    [getStateKey('channel', opsChannel.key)]: 1,
    [getStateKey('contact', alice.public_key)]: 3,
    [getStateKey('contact', board.public_key)]: 5,
    [getStateKey('contact', relay.public_key)]: 4,
  };

  const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
  const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];

  const view = render(
    <Sidebar
      contacts={[alice, board, relay]}
      channels={channels}
      activeConversation={null}
      onSelectConversation={vi.fn()}
      onNewMessage={vi.fn()}
      lastMessageTimes={overrides?.lastMessageTimes ?? {}}
      unreadCounts={unreadCounts}
      mentions={overrides?.mentions ?? {}}
      showCracker={false}
      crackerRunning={false}
      onToggleCracker={vi.fn()}
      onMarkAllRead={vi.fn()}
      favorites={favorites}
      legacySortOrder="recent"
      isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
    />
  );

  return { ...view, flightChannel, opsChannel, aliceName, roomName };
}

function getSectionHeaderContainer(title: string): HTMLElement {
  const btn = screen.getByRole('button', { name: title });
  const container = btn.closest('div');
  if (!container) throw new Error(`Missing header container for section ${title}`);
  return container;
}

describe('Sidebar section summaries', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('shows muted section unread totals in each visible section header', () => {
    renderSidebar();

    expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
    expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
    expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
    expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
    expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
  });

  it('turns favorites and channels rollups red when they contain a mention', () => {
    renderSidebar({
      mentions: {
        [getStateKey('channel', 'BB'.repeat(16))]: true,
        [getStateKey('channel', 'CC'.repeat(16))]: true,
      },
    });

    expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toHaveClass(
      'bg-badge-mention',
      'text-badge-mention-foreground'
    );
    expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toHaveClass(
      'bg-badge-mention',
      'text-badge-mention-foreground'
    );
  });

  it('turns contact row badges red while the contacts rollup remains red', () => {
    const { aliceName } = renderSidebar();

    expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
      'bg-badge-mention',
      'text-badge-mention-foreground'
    );

    const aliceRow = screen.getByText(aliceName).closest('div');
    if (!aliceRow) throw new Error('Missing Alice row');
    expect(within(aliceRow).getByText('3')).toHaveClass(
      'bg-badge-mention',
      'text-badge-mention-foreground'
    );
  });

  it('turns favorite contact row badges red', () => {
    const { aliceName } = renderSidebar({
      favorites: [{ type: 'contact', id: '11'.repeat(32) }],
    });

    const aliceRow = screen.getByText(aliceName).closest('div');
    if (!aliceRow) throw new Error('Missing Alice row');
    expect(within(aliceRow).getByText('3')).toHaveClass(
      'bg-badge-mention',
      'text-badge-mention-foreground'
    );
  });

  it('keeps repeater row badges neutral', () => {
    renderSidebar();

    const relayRow = screen.getByText('Relay').closest('div');
    if (!relayRow) throw new Error('Missing Relay row');
    expect(within(relayRow).getByText('4')).toHaveClass(
      'bg-badge-unread/90',
      'text-badge-unread-foreground'
    );
  });

  it('renders room servers in their own section', () => {
    const { roomName } = renderSidebar();

    expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
    expect(screen.getByText(roomName)).toBeInTheDocument();
  });

  it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
    const { opsChannel, aliceName, roomName } = renderSidebar();

    fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
    fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
    fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
    fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));

    expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
    expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
    expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
    expect(screen.queryByText(roomName)).not.toBeInTheDocument();

    const search = screen.getByLabelText('Search conversations');
    fireEvent.change(search, { target: { value: 'alice' } });

    await waitFor(() => {
      expect(screen.getByText(aliceName)).toBeInTheDocument();
    });

    fireEvent.change(search, { target: { value: '' } });

    await waitFor(() => {
      expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
      expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
      expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
      expect(screen.queryByText(roomName)).not.toBeInTheDocument();
    });
  });

  it('persists collapsed section state across unmount and remount', () => {
    const { opsChannel, aliceName, roomName, unmount } = renderSidebar();

    fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
    fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
    fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
    fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));

    expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
    expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
    expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
    expect(screen.queryByText(roomName)).not.toBeInTheDocument();

    unmount();
    renderSidebar();

    expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
    expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
    expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
    expect(screen.queryByText(roomName)).not.toBeInTheDocument();
  });

  it('renders same-name channels when keys differ and allows selecting both', () => {
    const publicChannel = makeChannel('AA'.repeat(16), 'Public');
    const channelA = makeChannel('DD'.repeat(16), '#shared');
    const channelB = makeChannel('EE'.repeat(16), '#shared');
    const onSelectConversation = vi.fn();

    render(
      <Sidebar
        contacts={[]}
        channels={[publicChannel, channelA, channelB]}
        activeConversation={null}
        onSelectConversation={onSelectConversation}
        onNewMessage={vi.fn()}
        lastMessageTimes={{}}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[]}
        legacySortOrder="recent"
      />
    );

    const sharedRows = screen.getAllByText('#shared');
    expect(sharedRows).toHaveLength(2);

    fireEvent.click(sharedRows[0]);
    fireEvent.click(sharedRows[1]);

    const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id);
    expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key]));
  });

  it('shows a notification bell for conversations with notifications enabled', () => {
    const { aliceName } = renderSidebar({
      unreadCounts: {},
      isConversationNotificationsEnabled: (type, id) =>
        (type === 'contact' && id === '11'.repeat(32)) ||
        (type === 'channel' && id === 'BB'.repeat(16)),
    });

    const aliceRow = screen.getByText(aliceName).closest('div');
    const flightRow = screen.getByText('#flight').closest('div');
    if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows');

    expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
    expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
  });

  it('keeps the notification bell to the left of the unread pill when both are present', () => {
    const { aliceName } = renderSidebar({
      unreadCounts: {
        [getStateKey('contact', '11'.repeat(32))]: 3,
      },
      isConversationNotificationsEnabled: (type, id) =>
        type === 'contact' && id === '11'.repeat(32),
    });

    const aliceRow = screen.getByText(aliceName).closest('div');
    if (!aliceRow) throw new Error('Missing Alice row');

    const bell = within(aliceRow).getByLabelText('Notifications enabled');
    const unread = within(aliceRow).getByText('3');
    expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
  });

  it('sorts each section independently and persists per-section sort preferences', () => {
    const publicChannel = makeChannel('AA'.repeat(16), 'Public');
    const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
    const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
    const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
    const amy = makeContact('22'.repeat(32), 'Amy');
    const zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
      last_seen: 100,
    });
    const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
      last_advert: 300,
    });
    const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
      last_seen: 100,
    });
    const relayAlpha = makeContact('44'.repeat(32), 'Alpha Relay', CONTACT_TYPE_REPEATER, {
      last_seen: 300,
    });

    const props = {
      contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
      channels: [publicChannel, zebraChannel, alphaChannel],
      activeConversation: null,
      onSelectConversation: vi.fn(),
      onNewMessage: vi.fn(),
      lastMessageTimes: {
        [getStateKey('channel', zebraChannel.key)]: 300,
        [getStateKey('channel', alphaChannel.key)]: 100,
        [getStateKey('contact', zed.public_key)]: 200,
        [getStateKey('contact', zebraRoom.public_key)]: 350,
      },
      unreadCounts: {},
      mentions: {},
      showCracker: false,
      crackerRunning: false,
      onToggleCracker: vi.fn(),
      onMarkAllRead: vi.fn(),
      favorites: [],
      legacySortOrder: 'recent' as const,
    };

    const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
    const getContactsOrder = () =>
      screen
        .getAllByText(/^(Amy|Zed)$/)
        .map((node) => node.textContent)
        .filter((text): text is string => Boolean(text));
    const getRepeatersOrder = () =>
      screen
        .getAllByText(/Relay$/)
        .map((node) => node.textContent)
        .filter((text): text is string => Boolean(text));
    const getRoomsOrder = () =>
      screen
        .getAllByText(/Room$/)
        .map((node) => node.textContent)
        .filter((text): text is string => Boolean(text));

    const { unmount } = render(<Sidebar {...props} />);

    expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
    expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
    expect(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
    expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);

    fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
    fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
    fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));

    expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
    expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
    expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
    expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);

    unmount();
    render(<Sidebar {...props} />);

    expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
    expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
    expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
    expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
  });

  it('sorts room servers like contacts by DM recency first, then advert recency', () => {
    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
      last_advert: 100,
    });
    const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
      last_seen: 300,
    });
    const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);

    render(
      <Sidebar
        contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
        channels={[publicChannel]}
        activeConversation={null}
        onSelectConversation={vi.fn()}
        onNewMessage={vi.fn()}
        lastMessageTimes={{
          [getStateKey('contact', dmRecentRoom.public_key)]: 400,
        }}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[]}
        legacySortOrder="recent"
      />
    );

    const roomRows = screen
      .getAllByText(/Room$/)
      .map((node) => node.textContent)
      .filter((text): text is string => Boolean(text));

    expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
  });

  it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });
    const advertOnly = makeContact('22'.repeat(32), 'Advert Only', 1, { last_seen: 300 });
    const noRecency = makeContact('33'.repeat(32), 'No Recency');

    render(
      <Sidebar
        contacts={[noRecency, advertOnly, dmRecent]}
        channels={[publicChannel]}
        activeConversation={null}
        onSelectConversation={vi.fn()}
        onNewMessage={vi.fn()}
        lastMessageTimes={{
          [getStateKey('contact', dmRecent.public_key)]: 400,
        }}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[]}
        legacySortOrder="recent"
      />
    );

    const contactRows = screen
      .getAllByText(/^(DM Recent|Advert Only|No Recency)$/)
      .map((node) => node.textContent)
      .filter((text): text is string => Boolean(text));

    expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']);
  });

  it('sorts repeaters by heard recency even when message times disagree', () => {
    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const staleMessageRelay = makeContact(
      '44'.repeat(32),
      'Stale Message Relay',
      CONTACT_TYPE_REPEATER,
      {
        last_seen: 100,
      }
    );
    const freshAdvertRelay = makeContact(
      '55'.repeat(32),
      'Fresh Advert Relay',
      CONTACT_TYPE_REPEATER,
      {
        last_advert: 500,
      }
    );

    render(
      <Sidebar
        contacts={[staleMessageRelay, freshAdvertRelay]}
        channels={[publicChannel]}
        activeConversation={null}
        onSelectConversation={vi.fn()}
        onNewMessage={vi.fn()}
        lastMessageTimes={{
          [getStateKey('contact', staleMessageRelay.public_key)]: 1000,
          [getStateKey('contact', freshAdvertRelay.public_key)]: 50,
        }}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[]}
        legacySortOrder="recent"
      />
    );

    const repeaterRows = screen
      .getAllByText(/Relay$/)
      .map((node) => node.textContent)
      .filter((text): text is string => Boolean(text));

    expect(repeaterRows).toEqual(['Fresh Advert Relay', 'Stale Message Relay']);
  });

  it('pins only the canonical Public channel to the top of channel sorting', () => {
    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const fakePublic = makeChannel('DD'.repeat(16), 'Public');
    const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
    const onSelectConversation = vi.fn();

    render(
      <Sidebar
        contacts={[]}
        channels={[fakePublic, alphaChannel, publicChannel]}
        activeConversation={null}
        onSelectConversation={onSelectConversation}
        onNewMessage={vi.fn()}
        lastMessageTimes={{}}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[]}
        legacySortOrder="alpha"
      />
    );

    fireEvent.click(screen.getAllByText('Public')[0]);

    expect(onSelectConversation).toHaveBeenCalledWith({
      type: 'channel',
      id: PUBLIC_CHANNEL_KEY,
      name: 'Public',
    });
  });

  it('sorts favorites independently and persists the favorites sort preference', () => {
    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
    const amy = makeContact('22'.repeat(32), 'Amy');

    const props = {
      contacts: [zed, amy],
      channels: [publicChannel],
      activeConversation: null,
      onSelectConversation: vi.fn(),
      onNewMessage: vi.fn(),
      lastMessageTimes: {
        [getStateKey('contact', zed.public_key)]: 200,
      },
      unreadCounts: {},
      mentions: {},
      showCracker: false,
      crackerRunning: false,
      onToggleCracker: vi.fn(),
      onMarkAllRead: vi.fn(),
      favorites: [
        { type: 'contact', id: zed.public_key },
        { type: 'contact', id: amy.public_key },
      ] satisfies Favorite[],
      legacySortOrder: 'recent' as const,
    };

    const getFavoritesOrder = () =>
      screen
        .getAllByText(/^(Amy|Zed)$/)
        .map((node) => node.textContent)
        .filter((text): text is string => Boolean(text));

    const { unmount } = render(<Sidebar {...props} />);

    expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']);

    fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' }));

    expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);

    unmount();
    render(<Sidebar {...props} />);

    expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
  });

  it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => {
    localStorage.setItem('remoteterm-sortOrder', 'alpha');

    const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
    const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
    const amy = makeContact('22'.repeat(32), 'Amy');

    render(
      <Sidebar
        contacts={[zed, amy]}
        channels={[publicChannel]}
        activeConversation={null}
        onSelectConversation={vi.fn()}
        onNewMessage={vi.fn()}
        lastMessageTimes={{
          [getStateKey('contact', zed.public_key)]: 200,
        }}
        unreadCounts={{}}
        mentions={{}}
        showCracker={false}
        crackerRunning={false}
        onToggleCracker={vi.fn()}
        onMarkAllRead={vi.fn()}
        favorites={[
          { type: 'contact', id: zed.public_key },
          { type: 'contact', id: amy.public_key },
        ]}
      />
    );

    const favoriteRows = screen
      .getAllByText(/^(Amy|Zed)$/)
      .map((node) => node.textContent)
      .filter((text): text is string => Boolean(text));

    expect(favoriteRows).toEqual(['Amy', 'Zed']);
    expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument();
  });
});
