import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useMessages } from './use-messages';
import type { MessagingAdapter } from '../adapter';
import type { Message } from '../types';

const wrap = (adapter: MessagingAdapter) =>
  function Wrapper({ children }: { children: ReactNode }) {
    return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
  };

describe('useMessages', () => {
  it('loads history for the thread', async () => {
    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.messages).toHaveLength(1);
    expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
  });

  // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
  // before you had spoken in a thread EVERY message rendered as not-yours.
  it('marks ownership correctly before the user has sent anything', async () => {
    const adapter = new MockAdapter();
    await adapter.send('th_mock_1', 'an earlier message of mine');

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.messages).toHaveLength(2));

    // Never sent anything via the hook — ownership still resolves from currentActorId().
    expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
    expect(result.current.messages[1]!.mine).toBe(true); // from me
  });

  it('appends an optimistic message immediately on send', async () => {
    const adapter = new MockAdapter();
    let release!: () => void;
    vi.spyOn(adapter, 'send').mockImplementation(
      () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
    );

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => { void result.current.send('hi'); });

    await waitFor(() => expect(result.current.messages).toHaveLength(2));
    expect(result.current.messages[1]!.pending).toBe(true);
    expect(result.current.messages[1]!.mine).toBe(true);

    await act(async () => { release(); });
    await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
  });

  it('rolls back the optimistic message and reports error when send fails', async () => {
    const adapter = new MockAdapter();
    vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    await act(async () => {
      await expect(result.current.send('doomed')).rejects.toThrow('offline');
    });

    expect(result.current.messages).toHaveLength(1);
    expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
    expect(result.current.error).toBe('offline');
  });

  it('does not duplicate a message when the transport echoes it back', async () => {
    const adapter = new MockAdapter();
    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    // MockAdapter.send emits a 'message' event AND resolves with the same message.
    await act(async () => { await result.current.send('echo once'); });

    expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
  });

  it('collects typing user ids from subscribe events', async () => {
    const adapter = new MockAdapter();
    let emit!: (userId: string) => void;
    vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
      emit = (userId) => cb({ kind: 'typing', userId });
      return () => {};
    });

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    act(() => emit('pp_sofia'));
    expect(result.current.typingUserIds).toEqual(['pp_sofia']);
  });

  it('unsubscribes on unmount', async () => {
    const adapter = new MockAdapter();
    const off = vi.fn();
    vi.spyOn(adapter, 'subscribe').mockReturnValue(off);

    const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    unmount();

    expect(off).toHaveBeenCalled();
  });

  it('keeps a live message that arrives before history resolves', async () => {
    const adapter = new MockAdapter();
    let resolveHistory!: (msgs: Message[]) => void;
    vi.spyOn(adapter, 'history').mockImplementation(
      () => new Promise<Message[]>((res) => { resolveHistory = res; }),
    );
    let emit!: (m: Message) => void;
    vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
      emit = (m) => cb({ kind: 'message', message: m });
      return () => {};
    });

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });

    // A live message arrives while history() is still pending.
    act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' }));

    // History resolves afterwards with an older message.
    await act(async () => {
      resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]);
    });

    const texts = result.current.messages.map((m) => m.text);
    expect(texts).toContain('older');
    expect(texts).toContain('ping before history'); // must NOT be clobbered by history load
  });

  it('clears a typing indicator after its TTL elapses', async () => {
    vi.useFakeTimers();
    try {
      const adapter = new MockAdapter();
      let emit!: (userId: string) => void;
      vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
        emit = (userId) => cb({ kind: 'typing', userId });
        return () => {};
      });

      const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
      await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask

      act(() => emit('pp_sofia'));
      expect(result.current.typingUserIds).toEqual(['pp_sofia']);

      await act(async () => { await vi.advanceTimersByTimeAsync(3600); });
      expect(result.current.typingUserIds).toEqual([]);
    } finally {
      vi.useRealTimers();
    }
  });
});

describe('read receipts', () => {
  // REGRESSION: the hook reported a read of the newest message regardless of author, so sending a
  // message made the server echo a receipt for it and the sender's own bubble showed "Seen"
  // immediately — in DMs, groups and channels alike, before anyone had opened it.
  it('never reports a read of my own message', async () => {
    const adapter = new MockAdapter();
    const markRead = vi.spyOn(adapter, 'markRead');

    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));
    markRead.mockClear();

    await act(async () => {
      await result.current.send('a message from me');
    });

    // The newest message is now mine — reading it would be reading myself.
    const mine = result.current.messages.filter((m) => m.mine).map((m) => m.id);
    for (const call of markRead.mock.calls) expect(mine).not.toContain(call[1]);
  });

  it('does not mark my message seen just because the receipt came back', async () => {
    const adapter = new MockAdapter();
    const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
    await waitFor(() => expect(result.current.loading).toBe(false));

    await act(async () => {
      await result.current.send('hello there');
    });

    const mine = result.current.messages.find((m) => m.mine && m.text === 'hello there');
    expect(mine).toBeDefined();
    expect(result.current.seenIds.has(mine!.id)).toBe(false);
  });
});
