import React, { act } from 'react'
import type { Channel } from 'stream-chat'
import { describe, expect, it, vi, beforeEach } from 'vitest'

import { renderWithProviders, screen, waitFor } from '../../test/utils'

import { MessagingShell } from './index'

const queryChannelsMock = vi.fn()
const startChannelWithParticipantMock = vi.fn()

const baseClient = {
  userID: 'me-1',
  queryChannels: queryChannelsMock,
}

const baseService = {
  startChannelWithParticipant: startChannelWithParticipantMock,
}

let useMessagingReturn: Record<string, unknown> = {
  client: baseClient,
  isConnected: true,
  isLoading: false,
  error: null,
  refreshConnection: vi.fn(),
  service: baseService,
  debug: false,
}

vi.mock('../../hooks/useMessaging', () => ({
  useMessaging: () => useMessagingReturn,
}))

let capturedChannelViewProps: Record<string, unknown> = {}
vi.mock('../ChannelView', () => ({
  ChannelView: (props: Record<string, unknown>) => {
    capturedChannelViewProps = props
    return <div data-testid="channel-view" />
  },
}))

vi.mock('./LoadingState', () => ({
  LoadingState: () => <div data-testid="loading-state" />,
}))

vi.mock('./ErrorState', () => ({
  ErrorState: ({ message }: { message: string }) => (
    <div data-testid="error-state">{message}</div>
  ),
}))

const makeChannel = (id: string): Channel =>
  ({ id, cid: `messaging:${id}` }) as unknown as Channel

describe('MessagingShell', () => {
  beforeEach(() => {
    queryChannelsMock.mockReset()
    startChannelWithParticipantMock.mockReset()
    capturedChannelViewProps = {}
    useMessagingReturn = {
      client: baseClient,
      isConnected: true,
      isLoading: false,
      error: null,
      refreshConnection: vi.fn(),
      service: baseService,
      debug: false,
    }
  })

  it('does not refire queryChannels when initialParticipantData and onChannelSelect get fresh references on re-render', async () => {
    queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])

    const { rerender } = renderWithProviders(
      <MessagingShell
        initialParticipantFilter="other-1"
        initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
        onChannelSelect={(channel) => void channel}
      />
    )

    await waitFor(() => {
      expect(screen.getByTestId('channel-view')).toBeInTheDocument()
    })

    expect(queryChannelsMock).toHaveBeenCalledTimes(1)

    // Re-render with new inline references for the same logical values — this
    // is the README-documented usage pattern that previously re-fired the
    // effect on every render.
    rerender(
      <MessagingShell
        initialParticipantFilter="other-1"
        initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
        onChannelSelect={(channel) => void channel}
      />
    )
    rerender(
      <MessagingShell
        initialParticipantFilter="other-1"
        initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
        onChannelSelect={(channel) => void channel}
      />
    )

    expect(queryChannelsMock).toHaveBeenCalledTimes(1)
  })

  it('refires queryChannels when initialParticipantFilter actually changes', async () => {
    queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])

    const { rerender } = renderWithProviders(
      <MessagingShell initialParticipantFilter="other-1" />
    )

    await waitFor(() => {
      expect(queryChannelsMock).toHaveBeenCalledTimes(1)
    })

    rerender(<MessagingShell initialParticipantFilter="other-2" />)

    await waitFor(() => {
      expect(queryChannelsMock).toHaveBeenCalledTimes(2)
    })
  })

  it('forwards attachmentPreviewList to the selected channel view', async () => {
    queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])
    const AttachmentPreviewList = () => <div>staged attachment</div>

    renderWithProviders(
      <MessagingShell
        initialParticipantFilter="other-1"
        attachmentPreviewList={AttachmentPreviewList}
      />
    )

    await waitFor(() => {
      expect(screen.getByTestId('channel-view')).toBeInTheDocument()
    })

    expect(capturedChannelViewProps.attachmentPreviewList).toBe(
      AttachmentPreviewList
    )
  })

  it('calls onExitConversation and renders "Conversation ended" when the user leaves the conversation', async () => {
    queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])
    const onExit = vi.fn()

    renderWithProviders(
      <MessagingShell
        initialParticipantFilter="other-1"
        onExitConversation={onExit}
      />
    )

    await waitFor(() => {
      expect(screen.getByTestId('channel-view')).toBeInTheDocument()
    })

    const onLeave = capturedChannelViewProps.onLeaveConversation as () => void
    act(() => onLeave())

    expect(onExit).toHaveBeenCalledTimes(1)
    expect(screen.getByTestId('error-state')).toHaveTextContent(
      'Conversation ended'
    )
    expect(screen.queryByTestId('loading-state')).not.toBeInTheDocument()
  })

  it('renders the "Conversation ended" state even when no onExitConversation callback is provided (no permanent spinner)', async () => {
    queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])

    renderWithProviders(<MessagingShell initialParticipantFilter="other-1" />)

    await waitFor(() => {
      expect(screen.getByTestId('channel-view')).toBeInTheDocument()
    })

    const onBlock = capturedChannelViewProps.onBlockParticipant as (
      participantId?: string
    ) => void
    act(() => onBlock('other-1'))

    expect(screen.getByTestId('error-state')).toHaveTextContent(
      'Conversation ended'
    )
  })

  it('does not create the channel again when the load effect re-runs for the same pair (e.g. service identity settles during connect)', async () => {
    queryChannelsMock.mockResolvedValue([])
    startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-1'))

    const { rerender } = renderWithProviders(
      <MessagingShell
        initialParticipantFilter="other-1"
        initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
      />
    )

    await waitFor(() =>
      expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
    )

    // A fresh `service` reference (as happens while the connection settles)
    // re-runs the load effect. The once-per-pair guard must prevent a second
    // create for the same viewer/participant pair before the new channel is
    // indexed by queryChannels.
    useMessagingReturn = {
      ...useMessagingReturn,
      service: { startChannelWithParticipant: startChannelWithParticipantMock },
    }
    rerender(
      <MessagingShell
        initialParticipantFilter="other-1"
        initialParticipantData={{ id: 'other-1', name: 'Other Person' }}
      />
    )

    await new Promise((resolve) => setTimeout(resolve, 50))
    expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
  })

  it('does not wipe a newer pair load guard when an earlier in-flight load fails', async () => {
    const PAIR_A = 'pair-A'
    const PAIR_B = 'pair-B'

    // Pair A's query stays pending until we reject it, simulating a load still
    // in flight when the participant changes. Pair B resolves with no channel.
    let rejectA: (reason?: unknown) => void = () => {}
    queryChannelsMock.mockImplementation(
      (query: { members?: { $eq?: string[] } }) => {
        if (query?.members?.$eq?.includes(PAIR_A)) {
          return new Promise((_resolve, reject) => {
            rejectA = reject
          })
        }
        return Promise.resolve([])
      }
    )
    startChannelWithParticipantMock.mockResolvedValue(makeChannel('created-b'))

    const { rerender } = renderWithProviders(
      <MessagingShell
        initialParticipantFilter={PAIR_A}
        initialParticipantData={{ id: PAIR_A, name: 'A' }}
      />
    )

    // Switch to pair B while A's load is still pending; B creates its channel.
    rerender(
      <MessagingShell
        initialParticipantFilter={PAIR_B}
        initialParticipantData={{ id: PAIR_B, name: 'B' }}
      />
    )

    await waitFor(() =>
      expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
    )

    // A's stale load now fails. Its catch must not clear pair B's guard.
    rejectA(new Error('transient'))
    await new Promise((resolve) => setTimeout(resolve, 0))

    // Re-run the load effect for pair B (a fresh `service` reference). With B's
    // guard intact this must not create a second channel.
    useMessagingReturn = {
      ...useMessagingReturn,
      service: { startChannelWithParticipant: startChannelWithParticipantMock },
    }
    rerender(
      <MessagingShell
        initialParticipantFilter={PAIR_B}
        initialParticipantData={{ id: PAIR_B, name: 'B' }}
      />
    )

    await new Promise((resolve) => setTimeout(resolve, 50))
    expect(startChannelWithParticipantMock).toHaveBeenCalledTimes(1)
  })
})
