import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Incident, IncidentCliOption, IncidentsResponse } from '../../lib/incident-types'
import { deleteIncident, dispatchIncident, fetchIncident, fetchIncidentOptions, fetchIncidents, fileIncident, stopIncident } from '../../lib/collector-client'
import { IncidentsContent } from './IncidentsContent'

vi.mock('../../lib/collector-client', async () => {
  const actual = await vi.importActual<typeof import('../../lib/collector-client')>(
    '../../lib/collector-client',
  )
  return {
    CollectorHttpError: actual.CollectorHttpError,
    fetchIncidents: vi.fn(),
    fetchIncident: vi.fn(),
    fileIncident: vi.fn(),
    dispatchIncident: vi.fn(),
    stopIncident: vi.fn(),
    deleteIncident: vi.fn(),
    fetchIncidentOptions: vi.fn(),
  }
})

const { CollectorHttpError } = await vi.importActual<typeof import('../../lib/collector-client')>(
  '../../lib/collector-client',
)

function incident(
  overrides: Partial<Omit<Incident, 'dispatch'>> & { id: string; dispatch?: Partial<Incident['dispatch']> },
): Incident {
  return {
    kanboardTaskId: 1,
    title: 'Collector wedged',
    description: 'It stopped emitting deltas.',
    priority: 'P1',
    incidentType: null,
    dispatchBrief: null,
    dispatchBriefProvenance: null,
    state: 'running',
    active: true,
    createdAt: '2026-08-08T00:00:00.000Z',
    updatedAt: '2026-08-08T00:05:00.000Z',
    resolvedAt: null,
    activity: [],
    coverage: { stale: false },
    ...overrides,
    dispatch: {
      state: 'running',
      dispatchId: null,
      cli: 'codex',
      model: 'gpt-5',
      wrapperModel: null,
      reasoningEffort: 'high',
      account: 'zync',
      requestSha256: null,
      statusRevision: null,
      startedAt: null,
      heartbeatAt: null,
      completedAt: null,
      exitCode: null,
      failureClass: null,
      resultSummary: null,
      ...overrides.dispatch,
    },
  }
}

function listResponse(incidents: Incident[], stale = false, detail?: string): IncidentsResponse {
  return { incidents, coverage: stale ? { stale, ...(detail ? { detail } : {}) } : { stale }, page: { highWater: 100, nextCursor: null, exhausted: true, order: 'id_desc' } }
}

function renderContent() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return render(
    <QueryClientProvider client={queryClient}>
      <IncidentsContent />
    </QueryClientProvider>,
  )
}

const listMock = vi.mocked(fetchIncidents)
const detailMock = vi.mocked(fetchIncident)
const fileMock = vi.mocked(fileIncident)
const dispatchMock = vi.mocked(dispatchIncident)
const stopMock = vi.mocked(stopIncident)
const deleteMock = vi.mocked(deleteIncident)
const optionsMock = vi.mocked(fetchIncidentOptions)

const authorityClis: IncidentCliOption = {
  id: 'codex',
  label: 'Codex',
  models: [
    { id: 'gpt-5', efforts: ['low', 'medium', 'high'] },
    { id: 'gpt-5.6-sol', efforts: ['low', 'medium', 'high'] },
  ],
  accounts: [
    { slug: 'work', label: 'Work', ready: true, fixed: false },
    { slug: 'zync', label: 'Zync', ready: true, fixed: false },
  ],
  permissionModes: ['safe'],
}

describe('IncidentsContent', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    optionsMock.mockResolvedValue({ types: [], clis: [authorityClis] })
    detailMock.mockResolvedValue(incident({ id: 'INC-1' }))
    fileMock.mockResolvedValue({ incident: incident({ id: 'NEW-1', state: 'filed', dispatch: { ...incident({ id: 'x' }).dispatch, state: 'filed' } }) })
    dispatchMock.mockResolvedValue(incident({ id: 'INC-1', state: 'dispatching', dispatch: { ...incident({ id: 'x' }).dispatch, state: 'dispatching' } }))
    stopMock.mockResolvedValue(incident({ id: 'INC-1', active: false, state: 'needs-attention' }))
    deleteMock.mockResolvedValue(undefined)
  })

  afterEach(cleanup)

  it('confirms lifecycle actions, excludes every row while pending, and reports failure', async () => {
    const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
    let rejectStop!: (error: Error) => void
    stopMock.mockReturnValue(new Promise((_resolve, reject) => { rejectStop = reject }))
    listMock.mockResolvedValue(listResponse([
      incident({ id: 'INC-1', title: 'First active' }),
      incident({ id: 'INC-2', title: 'Second active' }),
    ]))
    renderContent()
    await screen.findByText('First active')

    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'Stop work' }))
    expect(confirm).toHaveBeenCalledWith('Stop active work for INC-1?')
    await screen.findByText('Working…')
    expect(screen.getByText('Action pending')).toBeInTheDocument()
    expect(screen.queryByRole('button', { name: 'Actions for INC-2' })).not.toBeInTheDocument()

    rejectStop(new Error('stop refused'))
    expect(await screen.findByRole('alert')).toHaveTextContent('incident action failed')
    expect(await screen.findByRole('button', { name: 'Actions for INC-2' })).toBeInTheDocument()
    confirm.mockRestore()
  })

  it('does not mutate when lifecycle confirmation is declined', async () => {
    vi.spyOn(window, 'confirm').mockReturnValue(false)
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })]))
    renderContent()
    fireEvent.click(await screen.findByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'Stop work' }))
    expect(stopMock).not.toHaveBeenCalled()
  })

  it('lists active incidents and keeps resolved ones in their own section', async () => {
    listMock.mockResolvedValue(
      listResponse([
        incident({ id: 'INC-1', title: 'Collector wedged' }),
        incident({ id: 'INC-2', title: 'Runner offline', active: false, state: 'resolved' }),
      ]),
    )

    renderContent()

    const activeTable = await screen.findByTestId('incidents-active-table')
    expect(within(activeTable).getByText('Collector wedged')).toBeInTheDocument()
    expect(within(activeTable).queryByText('Runner offline')).not.toBeInTheDocument()

    const resolvedTable = screen.getByTestId('incidents-resolved-table')
    expect(within(resolvedTable).getByText('Runner offline')).toBeInTheDocument()
    expect(screen.getByText('Resolved (1)')).toBeInTheDocument()
  })

  it('files from a drawer in canonical order with authoritative unsafe capability', async () => {
    optionsMock.mockResolvedValue({
      types: [],
      clis: [{ ...authorityClis, permissionModes: ['safe', 'unsafe'] }],
    })
    listMock.mockResolvedValue(listResponse([]))
    fileMock.mockResolvedValue({ incident: incident({ id: 'filed-1', title: 'New outage', state: 'filed' }) })
    renderContent()
    await screen.findByTestId('incidents-active-empty')

    expect(screen.queryByLabelText('Title')).not.toBeInTheDocument()
    fireEvent.click(screen.getByRole('button', { name: 'File incident' }))
    const drawer = await screen.findByRole('dialog')
    const labels = within(drawer).getAllByText(/^(Title|Description|CLI|Model|Reasoning effort|Account|Unsafe|Priority)$/)
    expect(labels.map((label) => label.textContent)).toEqual([
      'Title', 'Description', 'CLI', 'Model', 'Reasoning effort', 'Account', 'Unsafe', 'Priority',
    ])

    fireEvent.change(within(drawer).getByLabelText('Title'), { target: { value: 'New outage' } })
    fireEvent.change(within(drawer).getByLabelText('Description'), { target: { value: 'Collector stopped.' } })
    const pick = async (label: string, option: string): Promise<void> => {
      fireEvent.click(within(drawer).getByRole('combobox', { name: label }))
      fireEvent.click(await screen.findByRole('option', { name: option }))
    }
    await pick('CLI', 'Codex')
    await pick('Model', 'gpt-5')
    await pick('Reasoning effort', 'high')
    await pick('Account', 'Work')
    fireEvent.click(within(drawer).getByLabelText('Unsafe'))
    await pick('Priority', 'P1 · High')
    fireEvent.click(within(drawer).getByRole('button', { name: 'File and dispatch' }))

    await waitFor(() => expect(fileMock).toHaveBeenCalledWith(expect.objectContaining({
      title: 'New outage', description: 'Collector stopped.', cli: 'codex', model: 'gpt-5',
      reasoningEffort: 'high', account: 'work', unsafe: true, priority: 'P1',
      requestId: expect.any(String),
    })))
    expect(await screen.findByRole('dialog')).toHaveTextContent('New outage')
  })

  it('shows the empty-active copy only when the store answered with no active incidents', async () => {
    listMock.mockResolvedValue(listResponse([]))

    renderContent()

    expect(await screen.findByTestId('incidents-active-empty')).toHaveTextContent('No active incidents.')
  })

  it('never reuses the empty copy for an unreachable store', async () => {
    listMock.mockRejectedValue(new CollectorHttpError(503, { error: 'incidents-store-unavailable' }, 'boom'))

    renderContent()

    const error = await screen.findByTestId('incidents-store-error')
    expect(error).toHaveTextContent('The incident store is unreachable')
    expect(screen.queryByTestId('incidents-active-empty')).not.toBeInTheDocument()
    expect(screen.queryByText('No active incidents.')).not.toBeInTheDocument()
  })

  it('distinguishes an unconfigured store from an unreachable one', async () => {
    listMock.mockRejectedValue(new CollectorHttpError(404, { error: 'not-found' }, 'boom'))

    renderContent()

    expect(await screen.findByTestId('incidents-store-error')).toHaveTextContent(
      'no incident store configured',
    )
  })

  it('renders unknown priority and absent timestamps as em dashes', async () => {
    listMock.mockResolvedValue(
      listResponse([incident({ id: 'INC-1', priority: null, createdAt: null, updatedAt: null })]),
    )

    renderContent()

    const table = await screen.findByTestId('incidents-active-table')
    const cells = within(table).getAllByRole('cell')
    const dashes = cells.filter((cell) => cell.textContent === '—')
    expect(dashes).toHaveLength(3)
    expect(within(table).queryByText('0')).not.toBeInTheDocument()
  })

  it('filters the list by the typed text', async () => {
    listMock.mockResolvedValue(
      listResponse([
        incident({ id: 'INC-1', title: 'Collector wedged' }),
        incident({ id: 'INC-2', title: 'Runner offline' }),
      ]),
    )

    renderContent()
    await screen.findByTestId('incidents-active-table')

    fireEvent.change(screen.getByLabelText('Filter incidents'), { target: { value: 'runner' } })

    const table = screen.getByTestId('incidents-active-table')
    expect(within(table).getByText('Runner offline')).toBeInTheDocument()
    expect(within(table).queryByText('Collector wedged')).not.toBeInTheDocument()
  })

  it('opens the detail drawer and shows lifecycle entries from the detail route', async () => {
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })]))
    detailMock.mockResolvedValue(
      incident({
        id: 'INC-1',
        activity: [
          { id: 2, at: '2026-08-08T00:04:00.000Z', comment: 'dispatched', username: 'overdeck' },
          { id: 1, at: '2026-08-08T00:02:00.000Z', comment: 'filed', username: 'overdeck' },
        ],
      }),
    )

    renderContent()
    await screen.findByTestId('incidents-active-table')

    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))

    const drawer = await screen.findByRole('dialog')
    expect(within(drawer).getByText('INC-1')).toBeInTheDocument()

    const activity = await within(drawer).findByTestId('incident-activity-table')
    const rows = within(activity).getAllByRole('row').slice(1)
    expect(rows.map((row) => row.textContent)).toEqual([
      expect.stringContaining('filed'),
      expect.stringContaining('dispatched'),
    ])
    expect(detailMock).toHaveBeenCalledWith('INC-1')
  })

  it('sorts lifecycle entries when the timestamp header is activated', async () => {
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })]))
    detailMock.mockResolvedValue(
      incident({
        id: 'INC-1',
        activity: [
          { id: 1, at: '2026-08-08T00:02:00.000Z', comment: 'filed', username: 'overdeck' },
          { id: 2, at: '2026-08-08T00:04:00.000Z', comment: 'dispatched', username: 'overdeck' },
        ],
      }),
    )

    renderContent()
    await screen.findByTestId('incidents-active-table')
    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))

    const table = await within(await screen.findByRole('dialog')).findByTestId('incident-activity-table')
    const entries = () => within(table).getAllByRole('row').slice(1).map((row) => row.textContent)
    expect(entries()).toEqual([expect.stringContaining('filed'), expect.stringContaining('dispatched')])

    fireEvent.click(within(table).getByRole('button', { name: /When/ }))
    await waitFor(() => expect(entries()).toEqual([expect.stringContaining('dispatched'), expect.stringContaining('filed')]))
  })

  it('reports a failed detail fetch instead of claiming there is no history', async () => {
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })]))
    detailMock.mockRejectedValue(new CollectorHttpError(503, {}, 'boom'))

    renderContent()
    await screen.findByTestId('incidents-active-table')

    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))

    const drawer = await screen.findByRole('dialog')
    await waitFor(() => {
      expect(within(drawer).getByText('Lifecycle history could not be loaded.')).toBeInTheDocument()
    })
    expect(within(drawer).queryByText('No lifecycle entries recorded.')).not.toBeInTheDocument()
  })

  it('surfaces incomplete coverage instead of presenting the list as whole', async () => {
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })], true, 'bootstrap-degraded:columns'))

    renderContent()
    await screen.findByTestId('incidents-active-table')

    expect(screen.getByText('Data coverage')).toBeInTheDocument()
    expect(screen.getByText(/bootstrap-degraded:columns/)).toBeInTheDocument()
  })

  it('re-sorts by priority when the header is activated', async () => {
    listMock.mockResolvedValue(
      listResponse([
        incident({ id: 'INC-1', title: 'Low', priority: 'P3' }),
        incident({ id: 'INC-2', title: 'Critical', priority: 'P0' }),
      ]),
    )

    renderContent()
    const table = await screen.findByTestId('incidents-active-table')

    const titles = () =>
      within(table).getAllByRole('row').slice(1).map((row) => within(row).getAllByRole('cell')[0]?.textContent)

    expect(titles()).toEqual(['Critical', 'Low'])

    fireEvent.click(within(table).getByRole('button', { name: /Priority/ }))
    await waitFor(() => expect(titles()).toEqual(['Critical', 'Low']))

    fireEvent.click(within(table).getByRole('button', { name: /Priority/ }))
    await waitFor(() => expect(titles()).toEqual(['Low', 'Critical']))
  })

  it('fails closed when authoritative filing options are unavailable', async () => {
    optionsMock.mockRejectedValue(new CollectorHttpError(503, { error: 'incident-assets-unavailable' }, 'boom'))
    listMock.mockResolvedValue(listResponse([]))
    renderContent()
    await screen.findByTestId('incidents-active-empty')
    fireEvent.click(screen.getByRole('button', { name: 'File incident' }))

    const drawer = await screen.findByRole('dialog')
    expect(within(drawer).getByRole('alert')).toHaveTextContent('options are unavailable')
    expect(within(drawer).getByRole('button', { name: 'File and dispatch' })).toBeDisabled()
    expect(fileMock).not.toHaveBeenCalled()
  })

  it('dispatches only a filed incident from its detail drawer', async () => {
    const filed = incident({ id: 'INC-1', state: 'filed', dispatch: { ...incident({ id: 'x' }).dispatch, state: 'filed' } })
    listMock.mockResolvedValue(listResponse([filed]))
    detailMock.mockResolvedValue(filed)
    renderContent()
    await screen.findByTestId('incidents-active-table')
    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))
    fireEvent.click(await screen.findByRole('button', { name: 'Dispatch incident' }))
    await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith('INC-1', {}))
  })

  it('hides the type dropdown when the options endpoint is unavailable', async () => {
    optionsMock.mockRejectedValue(new CollectorHttpError(503, { error: 'incident-assets-unavailable' }, 'boom'))
    listMock.mockResolvedValue(listResponse([]))
    renderContent()
    await screen.findByTestId('incidents-active-empty')
    expect(screen.queryByLabelText('Type')).not.toBeInTheDocument()
  })

  it('renders the persisted dispatch brief read-only in the drawer', async () => {
    const briefed = incident({ id: 'INC-1', dispatchBrief: '# Incident INC-1\n\nDo not touch never-touch paths.' })
    listMock.mockResolvedValue(listResponse([briefed]))
    detailMock.mockResolvedValue(briefed)
    renderContent()
    await screen.findByTestId('incidents-active-table')
    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))

    const drawer = await screen.findByRole('dialog')
    const brief = await within(drawer).findByTestId('incident-dispatch-brief')
    expect(brief).toHaveTextContent('Do not touch never-touch paths.')
  })

  it('omits the brief section when no brief was persisted', async () => {
    listMock.mockResolvedValue(listResponse([incident({ id: 'INC-1' })]))
    renderContent()
    await screen.findByTestId('incidents-active-table')
    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))

    const drawer = await screen.findByRole('dialog')
    await waitFor(() => expect(within(drawer).queryByTestId('incident-dispatch-brief')).not.toBeInTheDocument())
  })

  it('surfaces the named brief refusal and offers the explicit without-brief dispatch', async () => {
    const filed = incident({ id: 'INC-1', state: 'filed', dispatch: { ...incident({ id: 'x' }).dispatch, state: 'filed' } })
    listMock.mockResolvedValue(listResponse([filed]))
    detailMock.mockResolvedValue(filed)
    dispatchMock.mockRejectedValueOnce(new CollectorHttpError(
      422,
      { error: 'brief-assembly-failed', detail: 'dispatch blocked: brief assembly failed — missing required asset: taxonomy.json' },
      'blocked',
    ))
    renderContent()
    await screen.findByTestId('incidents-active-table')
    fireEvent.click(screen.getByRole('button', { name: 'Actions for INC-1' }))
    fireEvent.click(screen.getByRole('menuitem', { name: 'View details' }))
    fireEvent.click(await screen.findByRole('button', { name: 'Dispatch incident' }))

    const refusal = await screen.findByText('dispatch blocked: brief assembly failed — missing required asset: taxonomy.json')
    expect(refusal).toBeInTheDocument()

    dispatchMock.mockResolvedValueOnce(incident({ id: 'INC-1', state: 'dispatching', dispatch: { ...incident({ id: 'x' }).dispatch, state: 'dispatching' } }))
    fireEvent.click(screen.getByRole('button', { name: 'Dispatch without brief' }))
    await waitFor(() => expect(dispatchMock).toHaveBeenLastCalledWith('INC-1', { withoutBrief: true }))
  })
})
