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

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

import { ChannelActionsMenu } from './index'

const { getBlockedUsersMock, blockUserMock, unBlockUserMock } = vi.hoisted(
  () => ({
    getBlockedUsersMock: vi.fn().mockResolvedValue([]),
    blockUserMock: vi.fn().mockResolvedValue(undefined),
    unBlockUserMock: vi.fn().mockResolvedValue(undefined),
  })
)

// Stable service + context references — production MessagingProvider keeps
// the service in useState and memoizes the context value, so the hook's
// service-keyed lookup invalidation only fires on real swaps. Returning a
// new object literal each call would re-trigger the lookup every render and
// loop forever.
const mockService = {
  getBlockedUsers: getBlockedUsersMock,
  blockUser: blockUserMock,
  unBlockUser: unBlockUserMock,
}
const mockContext = { service: mockService, debug: false }
vi.mock('../../providers/MessagingProvider', () => ({
  useMessagingContext: () => mockContext,
}))

vi.mock('../ActionButton', () => ({
  default: ({
    children,
    onClick,
    disabled,
  }: {
    children: React.ReactNode
    onClick?: () => void
    disabled?: boolean
  }) => (
    <button data-testid="action-button" onClick={onClick} disabled={disabled}>
      {children}
    </button>
  ),
}))

vi.mock('@phosphor-icons/react', () => ({
  DotsThreeIcon: () => <span data-testid="dots-icon" />,
  FlagIcon: () => <span data-testid="flag-icon" />,
  ProhibitInsetIcon: () => <span data-testid="prohibit-icon" />,
  SignOutIcon: () => <span data-testid="signout-icon" />,
  SpinnerGapIcon: () => <span data-testid="spinner-icon" />,
}))

const createChannel = () =>
  ({
    id: 'channel-1',
    cid: 'messaging:channel-1',
    data: {},
    _client: { userID: 'visitor-1' },
    state: {
      members: {},
      membership: {},
      messages: [],
    },
    hide: vi.fn(),
  }) as unknown as Channel

const createParticipant = () =>
  ({
    user: {
      id: 'linker-1',
      name: 'Linker',
    },
    role: 'member',
  }) as unknown as ChannelMemberResponse

const defaultProps = () => ({
  channel: createChannel(),
  participant: createParticipant(),
})

const openMenu = async () =>
  userEvent.click(screen.getByRole('button', { name: 'More options' }))

describe('ChannelActionsMenu', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    getBlockedUsersMock.mockResolvedValue([])
  })

  it('renders the trigger but keeps options hidden until opened', () => {
    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)

    expect(
      screen.getByRole('button', { name: 'More options' })
    ).toBeInTheDocument()
    expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
    expect(screen.queryByText('Block')).not.toBeInTheDocument()
    expect(screen.queryByText('Report')).not.toBeInTheDocument()
  })

  it('reflects open state via aria-expanded and shows the options inline', async () => {
    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)

    const trigger = screen.getByRole('button', { name: 'More options' })
    expect(trigger).toHaveAttribute('aria-expanded', 'false')

    await openMenu()

    expect(trigger).toHaveAttribute('aria-expanded', 'true')
    expect(screen.getByText('Delete Conversation')).toBeInTheDocument()
    expect(screen.getByText('Block')).toBeInTheDocument()
    expect(screen.getByText('Report')).toBeInTheDocument()
    // Each option renders its icon inline.
    expect(screen.getByTestId('signout-icon')).toBeInTheDocument()
    expect(screen.getByTestId('prohibit-icon')).toBeInTheDocument()
    expect(screen.getByTestId('flag-icon')).toBeInTheDocument()
  })

  it('closes the popover when Escape is pressed', async () => {
    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)

    await openMenu()
    expect(screen.getByText('Report')).toBeInTheDocument()

    await userEvent.keyboard('{Escape}')

    await waitFor(() => {
      expect(screen.queryByText('Report')).not.toBeInTheDocument()
    })
  })

  it('closes the popover when clicking outside', async () => {
    renderWithProviders(
      <div>
        <span data-testid="outside">outside</span>
        <ChannelActionsMenu {...defaultProps()} />
      </div>
    )

    await openMenu()
    expect(screen.getByText('Report')).toBeInTheDocument()

    await userEvent.click(screen.getByTestId('outside'))

    await waitFor(() => {
      expect(screen.queryByText('Report')).not.toBeInTheDocument()
    })
  })

  it('calls onDeleteConversationClick and closes after deleting', async () => {
    const onDeleteConversationClick = vi.fn()
    renderWithProviders(
      <ChannelActionsMenu
        {...defaultProps()}
        onDeleteConversationClick={onDeleteConversationClick}
      />
    )

    await openMenu()
    await userEvent.click(screen.getByText('Delete Conversation'))

    await waitFor(() => {
      expect(onDeleteConversationClick).toHaveBeenCalledOnce()
    })
    await waitFor(() => {
      expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
    })
  })

  it('calls onBlockParticipantClick and blocks the user', async () => {
    const onBlockParticipantClick = vi.fn()
    renderWithProviders(
      <ChannelActionsMenu
        {...defaultProps()}
        onBlockParticipantClick={onBlockParticipantClick}
      />
    )

    await openMenu()
    await userEvent.click(screen.getByText('Block'))

    await waitFor(() => {
      expect(onBlockParticipantClick).toHaveBeenCalledOnce()
      expect(blockUserMock).toHaveBeenCalledWith('linker-1')
    })
  })

  it('shows Unblock when the participant is already blocked', async () => {
    getBlockedUsersMock.mockResolvedValue([{ blocked_user_id: 'linker-1' }])
    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)

    await openMenu()

    await waitFor(() => {
      expect(screen.getByText('Unblock')).toBeInTheDocument()
    })
    expect(screen.queryByText('Block')).not.toBeInTheDocument()
  })

  it('disables the block action until the blocked-status lookup resolves', async () => {
    let resolveBlockedUsers: (
      value: Array<{ blocked_user_id: string }>
    ) => void = () => {}
    getBlockedUsersMock.mockImplementation(
      () =>
        new Promise((resolve) => {
          resolveBlockedUsers = resolve
        })
    )

    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
    await openMenu()

    // While the lookup is pending the action is disabled so a premature click
    // can't act on a stale block state.
    const pendingBlock = screen.getByText('Block').closest('button')
    expect(pendingBlock).toBeDisabled()
    await userEvent.click(pendingBlock as HTMLElement)
    expect(blockUserMock).not.toHaveBeenCalled()

    // Once the lookup resolves (already blocked) the Unblock action is offered.
    resolveBlockedUsers([{ blocked_user_id: 'linker-1' }])
    await waitFor(() => {
      expect(screen.getByText('Unblock')).toBeInTheDocument()
    })
  })

  it('recovers from a blocked-status lookup failure', async () => {
    // Suppress the expected console.error for the rejected lookup so the
    // test output stays clean.
    const consoleErrorSpy = vi
      .spyOn(console, 'error')
      .mockImplementation(() => {})
    getBlockedUsersMock.mockRejectedValueOnce(new Error('network down'))

    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
    await openMenu()

    // The lookup rejected, but the action recovers — Block becomes actionable
    // rather than staying stuck in the disabled spinner state.
    await waitFor(() => {
      const block = screen.getByText('Block').closest('button')
      expect(block).not.toBeDisabled()
    })

    consoleErrorSpy.mockRestore()
  })

  it('calls onReportParticipantClick and opens the report page', async () => {
    const onReportParticipantClick = vi.fn()
    const windowOpenSpy = vi
      .spyOn(window, 'open')
      .mockImplementation(() => null)
    renderWithProviders(
      <ChannelActionsMenu
        {...defaultProps()}
        onReportParticipantClick={onReportParticipantClick}
      />
    )

    await openMenu()
    await userEvent.click(screen.getByText('Report'))

    await waitFor(() => {
      expect(onReportParticipantClick).toHaveBeenCalledOnce()
      expect(windowOpenSpy).toHaveBeenCalled()
    })
    windowOpenSpy.mockRestore()
  })

  it('hides actions based on the show* flags', async () => {
    renderWithProviders(
      <ChannelActionsMenu
        {...defaultProps()}
        showDeleteConversation={false}
        showBlockParticipant={false}
        showReportParticipant={false}
        customChannelActions={<li data-testid="custom-action">Custom</li>}
      />
    )

    await openMenu()

    expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
    expect(screen.queryByText('Block')).not.toBeInTheDocument()
    expect(screen.queryByText('Report')).not.toBeInTheDocument()
    expect(screen.getByTestId('custom-action')).toBeInTheDocument()
  })

  it('does not fetch blocked users until the menu is opened', async () => {
    renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)

    expect(getBlockedUsersMock).not.toHaveBeenCalled()

    await openMenu()

    await waitFor(() => {
      expect(getBlockedUsersMock).toHaveBeenCalled()
    })
  })

  it('renders nothing when there is no participant', () => {
    const { container } = renderWithProviders(
      <ChannelActionsMenu {...defaultProps()} participant={undefined} />
    )

    expect(container).toBeEmptyDOMElement()
  })

  it('renders nothing when no actions are available', () => {
    const { container } = renderWithProviders(
      <ChannelActionsMenu
        {...defaultProps()}
        showDeleteConversation={false}
        showBlockParticipant={false}
        showReportParticipant={false}
      />
    )

    expect(container).toBeEmptyDOMElement()
  })
})
