/**
 * Tests for useUnreadCounts hook.
 *
 * Focuses on the fix for stale server-side unreads overwriting local state
 * when the user is viewing a conversation (e.g. after WS reconnect or
 * contact/channel count change triggers a server re-fetch).
 */

import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

import { useUnreadCounts } from '../hooks/useUnreadCounts';
import type { Channel, Contact, Conversation, Message } from '../types';
import { getStateKey } from '../utils/conversationState';

// Mock api module
vi.mock('../api', () => ({
  api: {
    getUnreads: vi.fn(),
    markChannelRead: vi.fn().mockResolvedValue({ status: 'ok', key: '' }),
    markContactRead: vi.fn().mockResolvedValue({ status: 'ok', public_key: '' }),
    markAllRead: vi.fn().mockResolvedValue({ status: 'ok' }),
  },
}));

// Mock prefetch — takePrefetchOrFetch calls the fetcher directly
vi.mock('../prefetch', () => ({
  takePrefetchOrFetch: vi.fn((_key: string, fetcher: () => Promise<unknown>) => fetcher()),
}));

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

function makeContact(pubkey: string): Contact {
  return {
    public_key: pubkey,
    name: `Contact-${pubkey.slice(0, 6)}`,
    type: 1,
    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,
  };
}

function makeMessage(overrides: Partial<Message> = {}): Message {
  return {
    id: 1,
    type: 'PRIV',
    conversation_key: CONTACT_KEY,
    text: 'hello',
    sender_timestamp: 1700000000,
    received_at: 1700000001,
    paths: null,
    txt_type: 0,
    signature: null,
    sender_key: null,
    outgoing: false,
    acked: 0,
    sender_name: null,
    ...overrides,
  };
}

const CHANNEL_KEY = 'AABB00112233445566778899AABBCCDD';
const CONTACT_KEY = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';

// Get typed references to the mocked api functions
async function getMockedApi() {
  const { api } = await import('../api');
  return {
    getUnreads: vi.mocked(api.getUnreads),
    markChannelRead: vi.mocked(api.markChannelRead),
    markContactRead: vi.mocked(api.markContactRead),
    markAllRead: vi.mocked(api.markAllRead),
  };
}

describe('useUnreadCounts', () => {
  beforeEach(async () => {
    vi.clearAllMocks();
    const mocks = await getMockedApi();
    // Re-establish default resolvers (clearAllMocks wipes them)
    mocks.getUnreads.mockResolvedValue({
      counts: {},
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });
    mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' });
    mocks.markContactRead.mockResolvedValue({ status: 'ok', public_key: '' });
    mocks.markAllRead.mockResolvedValue({ status: 'ok', timestamp: 0 });
  });

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

  function renderWith({
    channels = [] as Channel[],
    contacts = [] as Contact[],
    activeConversation = null as Conversation | null,
  } = {}) {
    return renderHook(
      ({ channels: ch, contacts: ct, activeConversation: ac }) => useUnreadCounts(ch, ct, ac),
      { initialProps: { channels, contacts, activeConversation } }
    );
  }

  it('filters out active channel conversation from server unreads', async () => {
    const mocks = await getMockedApi();
    const channels = [makeChannel(CHANNEL_KEY, 'Test')];

    // Server reports 5 unreads for the channel we're viewing
    mocks.getUnreads.mockResolvedValue({
      counts: { [`channel-${CHANNEL_KEY}`]: 5 },
      mentions: { [`channel-${CHANNEL_KEY}`]: true },
      last_message_times: {},
      last_read_ats: { [`channel-${CHANNEL_KEY}`]: 1234 },
    });

    const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };
    const { result } = renderWith({ channels, activeConversation: activeConv });

    // Wait for the initial fetch + apply
    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    // The active conversation should NOT have unreads
    expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
    expect(result.current.mentions[`channel-${CHANNEL_KEY}`]).toBeUndefined();
    expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(1234);
  });

  it('filters out active contact conversation from server unreads', async () => {
    const mocks = await getMockedApi();
    const contacts = [makeContact(CONTACT_KEY)];

    mocks.getUnreads.mockResolvedValue({
      counts: { [`contact-${CONTACT_KEY}`]: 3 },
      mentions: {},
      last_message_times: {},
      last_read_ats: { [`contact-${CONTACT_KEY}`]: 2345 },
    });

    const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' };
    const { result } = renderWith({ contacts, activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    expect(result.current.unreadCounts[`contact-${CONTACT_KEY}`]).toBeUndefined();
    expect(result.current.unreadLastReadAts[`contact-${CONTACT_KEY}`]).toBe(2345);
  });

  it('preserves unreads for non-active conversations', async () => {
    const mocks = await getMockedApi();
    const otherKey = 'FFEEDDCCBBAA99887766554433221100';
    const channels = [makeChannel(CHANNEL_KEY, 'Active'), makeChannel(otherKey, 'Other')];

    mocks.getUnreads.mockResolvedValue({
      counts: {
        [`channel-${CHANNEL_KEY}`]: 5,
        [`channel-${otherKey}`]: 2,
      },
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Active' };
    const { result } = renderWith({ channels, activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    // Active channel filtered out, other channel preserved
    expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
    expect(result.current.unreadCounts[`channel-${otherKey}`]).toBe(2);
  });

  it('calls mark-read API for active channel after fetching unreads', async () => {
    const mocks = await getMockedApi();
    const channels = [makeChannel(CHANNEL_KEY, 'Test')];
    const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };

    renderWith({ channels, activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.markChannelRead).toHaveBeenCalledWith(CHANNEL_KEY));
    });
  });

  it('calls mark-read API for active contact after fetching unreads', async () => {
    const mocks = await getMockedApi();
    const contacts = [makeContact(CONTACT_KEY)];
    const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' };

    renderWith({ contacts, activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.markContactRead).toHaveBeenCalledWith(CONTACT_KEY));
    });
  });

  it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
    const mocks = await getMockedApi();
    const channels = [makeChannel(CHANNEL_KEY, 'Test')];
    const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };

    // Initial fetch: no unreads
    mocks.getUnreads.mockResolvedValueOnce({
      counts: {},
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    const { result } = renderWith({ channels, activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
    });

    // Simulate reconnect: server now reports unreads for the active conversation
    mocks.getUnreads.mockResolvedValueOnce({
      counts: { [`channel-${CHANNEL_KEY}`]: 7 },
      mentions: {},
      last_message_times: {},
      last_read_ats: { [`channel-${CHANNEL_KEY}`]: 3456 },
    });

    await act(async () => {
      await result.current.refreshUnreads();
    });

    // Should still be filtered out
    expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
    expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(3456);
  });

  it('re-fetches when channels change while contacts remain empty', async () => {
    const mocks = await getMockedApi();
    const initialChannels = [makeChannel(CHANNEL_KEY, 'Test')];
    const addedChannelKey = '11223344556677889900AABBCCDDEEFF';

    const { rerender } = renderWith({ channels: initialChannels, contacts: [] });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
    });

    mocks.getUnreads.mockResolvedValueOnce({
      counts: { [`channel-${addedChannelKey}`]: 2 },
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    rerender({
      channels: [...initialChannels, makeChannel(addedChannelKey, 'Added')],
      contacts: [],
      activeConversation: null,
    });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2));
    });
  });

  it('re-fetches when contacts change while channels remain empty', async () => {
    const mocks = await getMockedApi();
    const initialContact = makeContact(CONTACT_KEY);
    const addedContactKey = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100';

    const { rerender } = renderWith({ channels: [], contacts: [initialContact] });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1));
    });

    mocks.getUnreads.mockResolvedValueOnce({
      counts: { [`contact-${addedContactKey}`]: 1 },
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    rerender({
      channels: [],
      contacts: [initialContact, makeContact(addedContactKey)],
      activeConversation: null,
    });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2));
    });
  });

  it('does not filter when no active conversation', async () => {
    const mocks = await getMockedApi();
    mocks.getUnreads.mockResolvedValue({
      counts: { [`channel-${CHANNEL_KEY}`]: 5 },
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    const { result } = renderWith({});

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBe(5);
  });

  it('does not filter for non-conversation views (raw, map, visualizer)', async () => {
    const mocks = await getMockedApi();
    mocks.getUnreads.mockResolvedValue({
      counts: { [`channel-${CHANNEL_KEY}`]: 5 },
      mentions: {},
      last_message_times: {},
      last_read_ats: {},
    });

    const activeConv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
    const { result } = renderWith({ activeConversation: activeConv });

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    // Raw view doesn't filter any conversation's unreads
    expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBe(5);
  });

  it('recordMessageEvent updates last-message time and unread count for new inactive incoming messages', async () => {
    const mocks = await getMockedApi();
    const { result } = renderWith({});

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    const msg = makeMessage({
      id: 5,
      type: 'CHAN',
      conversation_key: CHANNEL_KEY,
      received_at: 1700001234,
    });

    await act(async () => {
      result.current.recordMessageEvent({
        msg,
        activeConversation: false,
        isNewMessage: true,
        hasMention: true,
      });
    });

    expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(1);
    expect(result.current.mentions[getStateKey('channel', CHANNEL_KEY)]).toBe(true);
    expect(result.current.lastMessageTimes[getStateKey('channel', CHANNEL_KEY)]).toBe(1700001234);
  });

  it('recordMessageEvent skips unread increment for active or non-new messages but still tracks time', async () => {
    const mocks = await getMockedApi();
    const { result } = renderWith({});

    await act(async () => {
      await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
    });

    const activeMsg = makeMessage({
      id: 6,
      type: 'PRIV',
      conversation_key: CONTACT_KEY,
      received_at: 1700002000,
    });

    await act(async () => {
      result.current.recordMessageEvent({
        msg: activeMsg,
        activeConversation: true,
        isNewMessage: true,
        hasMention: true,
      });
      result.current.recordMessageEvent({
        msg: makeMessage({
          id: 7,
          type: 'CHAN',
          conversation_key: CHANNEL_KEY,
          received_at: 1700002001,
        }),
        activeConversation: false,
        isNewMessage: false,
        hasMention: true,
      });
    });

    expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBeUndefined();
    expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBeUndefined();
    expect(result.current.lastMessageTimes[getStateKey('contact', CONTACT_KEY)]).toBe(1700002000);
    expect(result.current.lastMessageTimes[getStateKey('channel', CHANNEL_KEY)]).toBe(1700002001);
  });
});
