import { describe, it, expect, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Download } from './download'
import type { DownloadItem } from './types'
import * as exports from './exports'

function fakeItem(overrides?: Partial<DownloadItem>): DownloadItem {
  return {
    id: 'csv',
    label: 'Download as CSV',
    resolve: () =>
      Promise.resolve({
        url: 'blob:fake',
        filename: 'data.csv',
        revoke: vi.fn(),
      }),
    ...overrides,
  }
}

describe('<Download>', () => {
  it('renders a trigger and lists every item when opened', () => {
    render(
      <Download
        items={[
          fakeItem({ id: 'csv', label: 'Download CSV' }),
          fakeItem({ id: 'png', label: 'Download PNG' }),
        ]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    expect(screen.getByText('Download CSV')).toBeTruthy()
    expect(screen.getByText('Download PNG')).toBeTruthy()
  })

  it('honors custom trigger label', () => {
    render(
      <Download items={[fakeItem()]} labels={{ trigger: 'Save to disk' }} />,
    )
    expect(screen.getByRole('button', { name: 'Save to disk' })).toBeTruthy()
  })

  it('selecting an item calls its resolve(); the menu then closes', async () => {
    const resolve = vi.fn(() =>
      Promise.resolve({
        url: 'blob:abc',
        filename: 'x.csv',
        revoke: vi.fn(),
      }),
    )
    render(<Download items={[fakeItem({ resolve })]} />)
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    fireEvent.click(screen.getByText('Download as CSV'))
    expect(resolve).toHaveBeenCalledTimes(1)
    // Wait a tick for the resolve promise to settle.
    await Promise.resolve()
  })

  it('renders a custom icon component when provided', () => {
    function CustomIcon(props: React.SVGProps<SVGSVGElement>) {
      return <svg data-testid='custom-dl-icon' {...props} />
    }
    render(<Download items={[fakeItem()]} icon={CustomIcon} />)
    expect(screen.getByTestId('custom-dl-icon')).toBeTruthy()
  })

  it('renders a per-item leading icon inside ListItemIcon', () => {
    render(
      <Download
        items={[
          fakeItem({
            icon: <svg data-testid='item-icon' />,
          }),
        ]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    const icon = screen.getByTestId('item-icon')
    expect(icon.closest('.MuiListItemIcon-root')).not.toBeNull()
  })

  it('disables the menu item when the item is `disabled` and skips resolve()', () => {
    const resolve = vi.fn(() =>
      Promise.resolve({ url: 'blob:abc', filename: 'x.csv' }),
    )
    render(
      <Download
        items={[fakeItem({ id: 'csv', label: 'CSV', disabled: true, resolve })]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    const menuItem = screen.getByText('CSV').closest('li')
    expect(menuItem?.getAttribute('aria-disabled')).toBe('true')
    fireEvent.click(screen.getByText('CSV'))
    expect(resolve).not.toHaveBeenCalled()
  })

  it('overrides the resolved filename when `item.filename` is set', async () => {
    const trigger = vi
      .spyOn(exports, 'triggerLinkDownload')
      .mockImplementation(() => undefined)
    try {
      render(
        <Download
          items={[
            fakeItem({
              id: 'csv',
              label: 'CSV',
              filename: 'override.csv',
              resolve: () =>
                Promise.resolve({ url: 'blob:fake', filename: 'auto.csv' }),
            }),
          ]}
        />,
      )
      fireEvent.click(screen.getByRole('button', { name: 'Download' }))
      fireEvent.click(screen.getByText('CSV'))
      // wait for the resolve() promise to settle
      await Promise.resolve()
      await Promise.resolve()
      expect(trigger).toHaveBeenCalledWith({
        url: 'blob:fake',
        filename: 'override.csv',
      })
    } finally {
      trigger.mockRestore()
    }
  })

  it('uses the resolved filename when `item.filename` is not set', async () => {
    const trigger = vi
      .spyOn(exports, 'triggerLinkDownload')
      .mockImplementation(() => undefined)
    try {
      render(
        <Download
          items={[
            fakeItem({
              id: 'csv',
              label: 'CSV',
              resolve: () =>
                Promise.resolve({ url: 'blob:fake', filename: 'auto.csv' }),
            }),
          ]}
        />,
      )
      fireEvent.click(screen.getByRole('button', { name: 'Download' }))
      fireEvent.click(screen.getByText('CSV'))
      await Promise.resolve()
      await Promise.resolve()
      expect(trigger).toHaveBeenCalledWith({
        url: 'blob:fake',
        filename: 'auto.csv',
      })
    } finally {
      trigger.mockRestore()
    }
  })

  it('shows a spinner and disables the trigger while resolve() is pending; clears once it settles', async () => {
    const trigger = vi
      .spyOn(exports, 'triggerLinkDownload')
      .mockImplementation(() => undefined)
    let releaseResolve: (v: {
      url: string
      filename: string
      revoke?: () => void
    }) => void = () => undefined
    const pending = new Promise<{
      url: string
      filename: string
      revoke?: () => void
    }>((res) => {
      releaseResolve = res
    })
    try {
      render(
        <Download
          items={[
            fakeItem({ id: 'csv', label: 'CSV', resolve: () => pending }),
          ]}
        />,
      )
      const triggerBtn = screen.getByRole('button', { name: 'Download' })
      fireEvent.click(triggerBtn)
      fireEvent.click(screen.getByText('CSV'))

      // In flight: trigger label and aria-busy switch, button is disabled,
      // wrapper hover-fade `.active` className is applied, spinner is shown.
      const busyBtn = screen.getByRole('button', { name: 'Downloading…' })
      expect(busyBtn.getAttribute('aria-busy')).toBe('true')
      expect((busyBtn as HTMLButtonElement).disabled).toBe(true)
      expect(busyBtn.className).toMatch(/\bactive\b/)
      expect(busyBtn.querySelector('[role="progressbar"]')).not.toBeNull()

      // Settle the resolve and wait for the trigger to flip back to idle.
      releaseResolve({ url: 'blob:fake', filename: 'data.csv' })
      const idleBtn = await screen.findByRole('button', { name: 'Download' })
      expect(idleBtn.getAttribute('aria-busy')).toBeNull()
      expect((idleBtn as HTMLButtonElement).disabled).toBe(false)
      expect(idleBtn.querySelector('[role="progressbar"]')).toBeNull()
    } finally {
      trigger.mockRestore()
    }
  })

  it('clears the spinner when resolve() rejects and fires onError', async () => {
    const onError = vi.fn()
    render(
      <Download
        onError={onError}
        items={[
          fakeItem({
            id: 'csv',
            label: 'CSV',
            resolve: () => Promise.reject(new Error('boom')),
          }),
        ]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    fireEvent.click(screen.getByText('CSV'))
    // After the rejection the trigger label flips to the error label and
    // `data-error` is set; the spinner is cleared and the button is enabled.
    const errBtn = await screen.findByRole('button', {
      name: 'Download failed',
    })
    expect((errBtn as HTMLButtonElement).disabled).toBe(false)
    expect(errBtn.querySelector('[role="progressbar"]')).toBeNull()
    expect(errBtn.getAttribute('data-error')).toBe('true')
    await waitFor(() => expect(onError).toHaveBeenCalledTimes(1))
    const first = onError.mock.calls[0]?.[0] as Error | undefined
    expect(first).toBeInstanceOf(Error)
    expect(first?.message).toBe('boom')
  })

  it('normalises non-Error rejections into a real Error before onError', async () => {
    const onError = vi.fn()
    render(
      <Download
        onError={onError}
        items={[
          fakeItem({
            id: 'csv',
            label: 'CSV',
            // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
            resolve: () => Promise.reject('non-error-value'),
          }),
        ]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    fireEvent.click(screen.getByText('CSV'))
    await screen.findByRole('button', { name: 'Download failed' })
    const first = onError.mock.calls[0]?.[0] as Error | undefined
    expect(first).toBeInstanceOf(Error)
    expect(first?.message).toBe('non-error-value')
  })

  it('clears the error state when the menu is re-opened', async () => {
    render(
      <Download
        items={[
          fakeItem({
            id: 'csv',
            label: 'CSV',
            resolve: () => Promise.reject(new Error('boom')),
          }),
        ]}
      />,
    )
    fireEvent.click(screen.getByRole('button', { name: 'Download' }))
    fireEvent.click(screen.getByText('CSV'))
    const errBtn = await screen.findByRole('button', {
      name: 'Download failed',
    })
    fireEvent.click(errBtn)
    // After re-open the trigger flips back to the idle label and data-error
    // is gone. The menu is now open so we look up the trigger by its
    // aria-haspopup attribute rather than its role+name (other body nodes
    // get aria-hidden while the menu is open).
    await waitFor(() => {
      const btn = document.querySelector('button[aria-haspopup="true"]')
      expect(btn).not.toBeNull()
      expect(btn?.getAttribute('data-error')).toBeNull()
      expect(btn?.getAttribute('aria-label')).toBe('Download')
    })
  })

  it('defers revoke past the current task so the browser can dispatch the download', async () => {
    const trigger = vi
      .spyOn(exports, 'triggerLinkDownload')
      .mockImplementation(() => undefined)
    const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
    const revoke = vi.fn()
    try {
      render(
        <Download
          items={[
            fakeItem({
              id: 'csv',
              label: 'CSV',
              resolve: () =>
                Promise.resolve({
                  url: 'blob:abc',
                  filename: 'data.csv',
                  revoke,
                }),
            }),
          ]}
        />,
      )
      fireEvent.click(screen.getByRole('button', { name: 'Download' }))
      fireEvent.click(screen.getByText('CSV'))
      // Trigger fires inside the resolve callback; revoke is scheduled via
      // setTimeout(_, 0) rather than invoked inline. Wait until the trigger
      // call has happened — at that point revoke must be scheduled, not run.
      await waitFor(() => expect(trigger).toHaveBeenCalledTimes(1))
      expect(setTimeoutSpy).toHaveBeenCalledWith(revoke, 0)
      // After the queued timeout drains, revoke actually runs.
      await waitFor(() => expect(revoke).toHaveBeenCalledTimes(1))
    } finally {
      trigger.mockRestore()
      setTimeoutSpy.mockRestore()
    }
  })
})
