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 { FleetHost } from '@overdeck/deck-ui'
import { postCollectorAction } from '../../lib/action-client'
import { fetchCollectorItems, fetchCollectorState, fetchHostLogs } from '../../lib/collector-client'
import type { StateResponse } from '../../lib/collector-types'
import {
  CLUSTER_QUEUE_PANEL,
  FLEET_PANEL,
  OFFLOAD_CONTROL_PANEL,
  REMOTE_JOBS_PANEL,
} from '../../../tests/fixtures/ci-build-fixtures'
import { buildOffloadActionArgs, CiContent } from './CiContent'
import {
  machineCardMeta,
  machineModalActions,
  offloadKpiTiles,
  offloadControlFromPanel,
  remoteJobsFromPanel,
} from './ci-mappers'
import type { ClusterQueueWithKpis } from './ci-mappers'

vi.mock('../../lib/collector-client', () => ({
  fetchCollectorState: vi.fn(),
  fetchCollectorItems: vi.fn(),
  fetchHostLogs: vi.fn(),
}))

vi.mock('../../lib/action-client', () => ({
  postCollectorAction: vi.fn(),
}))

const mockToast = vi.fn()

vi.mock('../../lib/use-deck-toast', () => ({
  useDeckToast: () => ({ toast: mockToast }),
}))

const DEBIAN1 = (FLEET_PANEL.data as { hosts: FleetHost[] }).hosts.find((host) => host.host === 'debian1')!
const TS_FIXTURE = REMOTE_JOBS_PANEL.ts

function stateResponse(revision = 4192): StateResponse {
  return {
    panels: [
      { ...OFFLOAD_CONTROL_PANEL, data: { ...(OFFLOAD_CONTROL_PANEL.data as object), revision } },
      CLUSTER_QUEUE_PANEL,
      REMOTE_JOBS_PANEL,
      FLEET_PANEL,
    ],
    adapters: [{ id: 'offload', interval: 60_000, consecutiveErrors: 0, stale: false }],
  }
}

function renderCiContent() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  const view = render(
    <QueryClientProvider client={queryClient}>
      <CiContent />
    </QueryClientProvider>,
  )
  return { queryClient, ...view }
}

function confirmAction(label: string) {
  const dialog = screen.getByTestId('action-confirm')
  fireEvent.click(within(dialog).getByRole('button', { name: label }))
}

function expectRevisionInDialog(revision: number) {
  const dialog = screen.getByTestId('action-confirm')
  expect(dialog.textContent).toContain(`at revision ${revision}`)
}

async function openConfirmFromControl(label: string) {
  fireEvent.click(screen.getByRole('button', { name: label }))
  await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
}

describe('CiContent delivery state', () => {
  it('shows exact deployment state, failure reason, and next action', async () => {
    vi.mocked(fetchCollectorState).mockResolvedValue({
      ...stateResponse(),
      panels: [...stateResponse().panels, {
        id: 'deploy-status',
        ts: '2026-08-14T10:00:00.000Z',
        data: {
          state: 'stalled', complete: false, observedAt: '2026-08-14T10:00:00.000Z',
          queue: { depth: 1, oldestAgeMs: 120000, requests: [{ id: 'deploy-abc', observedAt: '2026-08-14T09:58:00.000Z' }] },
          operation: 'deploy-abc', holder: 'controller', latestProgressAt: '2026-08-14T09:59:00.000Z',
          latestEvent: { at: '2026-08-14T09:59:00.000Z', type: 'proof-failed', detail: 'installed identity mismatch' },
          reason: 'installed identity mismatch',
        },
      }],
      adapters: [...stateResponse().adapters, { id: 'deploy-status', interval: 30000, consecutiveErrors: 0, stale: false }],
    })
    vi.mocked(fetchCollectorItems).mockResolvedValue({ items: [] })
    renderCiContent()
    const delivery = await screen.findByTestId('ci-delivery-state')
    expect(delivery).toHaveTextContent('stalled')
    expect(delivery).toHaveTextContent('deploy-abc')
    expect(delivery).toHaveTextContent('installed identity mismatch')
    expect(delivery).toHaveTextContent('Next action not recorded')
    expect(delivery).toHaveTextContent('Exact wait not recorded')
    expect(delivery).toHaveTextContent('Lease owner not recorded')
    expect(delivery).toHaveTextContent('Remote node not recorded')
    expect(delivery).toHaveTextContent('Last receipt not recorded')
    expect(screen.getByTestId('ci-delivery-lag')).toHaveTextContent('never deployed yet')
    expect(screen.queryByTestId('ci-delivery-watcher-failure')).not.toBeInTheDocument()
  })

  it('surfaces a stopped auto-deploy watcher failure in owner language', async () => {
    const sha = 'a'.repeat(40)
    vi.mocked(fetchCollectorState).mockResolvedValue({
      ...stateResponse(),
      panels: [...stateResponse().panels, {
        id: 'deploy-status',
        ts: '2026-08-14T10:00:00.000Z',
        data: {
          state: 'idle', complete: true, observedAt: '2026-08-14T10:00:00.000Z',
          queue: { depth: 0, oldestAgeMs: null, requests: [] },
          operation: null, holder: null, latestProgressAt: null, latestEvent: null, reason: null,
          watcher: {
            targetSha: sha, attempts: 3, lastStatus: 'deploy-clone-dirty',
            lastDetail: 'local changes', lastAt: '2026-08-14T09:59:00.000Z', lastOk: false,
            failureClass: 'transient', nextRetryAt: null,
          },
        },
      }],
      adapters: [...stateResponse().adapters, { id: 'deploy-status', interval: 30000, consecutiveErrors: 0, stale: false }],
    })
    vi.mocked(fetchCollectorItems).mockResolvedValue({ items: [] })
    renderCiContent()
    await screen.findByTestId('ci-delivery-state')
    const failure = await screen.findByTestId('ci-delivery-watcher-failure')
    expect(failure).toHaveTextContent('stopped retrying')
    expect(failure).toHaveTextContent(sha.slice(0, 7))
    expect(failure).toHaveTextContent('needs a person to look')
  })
})
describe('buildOffloadActionArgs', () => {
  it('maps every supported verb to exact T3 args', () => {
    expect(buildOffloadActionArgs('box-drain', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('box-restore', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('host-quarantine', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      command: 'playwright browsers',
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('host-unquarantine', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      command: 'playwright browsers',
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('admission-reconcile', 4192, null)).toEqual({
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('ci-reconcile', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      expectedRevision: '4192',
    })
    expect(buildOffloadActionArgs('recall-spill', 4192, DEBIAN1)).toEqual({
      host: 'debian1',
      expectedRevision: '4192',
    })
  })

  it('rejects unknown verbs and missing prerequisites', () => {
    expect(buildOffloadActionArgs('job-retry', 4192, DEBIAN1)).toBeNull()
    expect(buildOffloadActionArgs('box-drain', 4192, null)).toBeNull()
    expect(
      buildOffloadActionArgs('host-quarantine', 4192, {
        ...DEBIAN1,
        capability: { probes: [], missingCommand: undefined },
      }),
    ).toBeNull()
  })

  it('refuses host actions when the registry marks the host unreachable', () => {
    const unreachable = { ...DEBIAN1, registryState: 'unreachable' as const }
    expect(buildOffloadActionArgs('box-drain', 4192, unreachable)).toBeNull()
    expect(machineCardMeta(unreachable)).toMatchObject({
      statusPill: 'declared unreachable',
      warn: true,
      actions: [],
    })
    expect(machineModalActions(unreachable)).toEqual([])
  })
})

describe('ci-mappers panel honesty', () => {
  it('labels every capability probe with the host it was taken on', () => {
    const probes = offloadControlFromPanel([OFFLOAD_CONTROL_PANEL])?.capabilityProbes ?? []
    expect(probes.map((probe) => probe.name)).toEqual([
      'laptop · full toolchain',
      'debian1 · tsc 5.9.3',
      'debian1 · vitest',
      'debian1 · pnpm',
      'debian1 · playwright browsers',
    ])
  })

  it('reports a stale remote-jobs panel as unknown rather than as no jobs', () => {
    expect(remoteJobsFromPanel([{ id: 'remote-jobs', ts: TS_FIXTURE, data: { stale: true } }]))
      .toBeUndefined()
    expect(remoteJobsFromPanel([REMOTE_JOBS_PANEL])).toHaveLength(
      (REMOTE_JOBS_PANEL.data as { jobs: unknown[] }).jobs.length,
    )
  })

  it('maps structured KPI samples without scraping incident titles', () => {
    const queue = {
      ...(CLUSTER_QUEUE_PANEL.data as object),
      kpis: {
        remote24h: 12,
        remoteSuccessPct: 75,
        exit127: { count: 2, hosts: ['debian1'] },
        longestPullMs: 6400,
      },
    } as ClusterQueueWithKpis
    const tiles = offloadKpiTiles(queue, [{
      id: 'offload:capability-missing', source: 'offload', severity: 'act', kind: 'build',
      title: 'Capability missing — misleading title', detail: '', ts: TS_FIXTURE, actions: [],
    }])
    expect(Object.fromEntries(tiles.map((tile) => [tile.key, tile.value]))).toEqual({
      remote24h: 12,
      remoteSuccessPct: 75,
      oldestQueuedMs: 11_520_000,
      exit127: 2,
      epochDiscarded: null,
      longestPullMs: 6400,
    })
  })
})

describe('CiContent with an unreadable buildbox registry', () => {
  beforeEach(() => {
    vi.mocked(fetchCollectorState).mockResolvedValue({
      panels: [
        { id: 'offload-control', ts: TS_FIXTURE, data: { stale: true } },
        { id: 'cluster-queue', ts: TS_FIXTURE, data: { stale: true } },
        { id: 'remote-jobs', ts: TS_FIXTURE, data: { stale: true } },
        {
          id: 'fleet',
          ts: TS_FIXTURE,
          data: { stale: true, hosts: [], registryError: 'buildbox registry unavailable: no such file' },
        },
      ],
      adapters: [{ id: 'offload', interval: 60_000, consecutiveErrors: 0, stale: false }],
    })
    vi.mocked(fetchCollectorItems).mockResolvedValue({ items: [] })
  })

  afterEach(() => {
    cleanup()
    vi.clearAllMocks()
  })

  it('names the failure and claims no fleet, no jobs and no machine cards', async () => {
    renderCiContent()

    await waitFor(() =>
      expect(screen.getByTestId('ci-build-offload')).toHaveTextContent(
        'buildbox registry unavailable: no such file',
      ),
    )
    const offload = screen.getByTestId('ci-build-offload')
    expect(offload).toHaveTextContent('Fleet not recorded.')
    expect(offload).toHaveTextContent('Remote jobs not recorded.')
    expect(offload).not.toHaveTextContent('builders online')
    expect(screen.queryByTestId('ci-build-fleet')).not.toBeInTheDocument()
  })
})

describe('CiContent offload actions', () => {
  let currentRevision = 4192

  beforeEach(() => {
    currentRevision = 4192
    vi.mocked(fetchCollectorState).mockImplementation(async () => stateResponse(currentRevision))
    vi.mocked(fetchCollectorItems).mockResolvedValue({ items: [] })
    vi.mocked(fetchHostLogs).mockResolvedValue([
      '2026-07-21T10:00:00Z builder ready',
      '2026-07-21T10:01:00Z dispatch ok',
    ])
    vi.mocked(postCollectorAction).mockResolvedValue({ ok: true, result: 'ok' })
    mockToast.mockReset()
  })

  afterEach(() => {
    cleanup()
    vi.clearAllMocks()
  })

  async function selectDebian1() {
    await waitFor(() => expect(screen.getByTestId('machine-card-debian1')).toBeInTheDocument())
    fireEvent.click(screen.getByTestId('machine-card-debian1'))
    await waitFor(() => expect(screen.getByTestId('machine-detail-modal')).toBeInTheDocument())
  }

  it('carries box-side guard facts from the collector payload into the detail modal', async () => {
    renderCiContent()
    await selectDebian1()

    const guard = within(screen.getByTestId('machine-detail-modal')).getByTestId('machine-guard-section')
    expect(guard).toHaveTextContent('agent.slice')
    expect(guard).toHaveTextContent('build.slice')
    expect(guard).not.toHaveTextContent('Guard facts not recorded for this host.')
  })

  it('reads revision at confirm time after dialog opens', async () => {
    const { queryClient } = renderCiContent()
    await selectDebian1()

    const drain = within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Drain' })
    expect(drain).not.toBeDisabled()
    fireEvent.click(drain)
    await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
    expectRevisionInDialog(4192)

    currentRevision = 4205
    await queryClient.invalidateQueries({ queryKey: ['collector-state'] })
    await waitFor(() => expectRevisionInDialog(4205))

    confirmAction('Drain')
    await waitFor(() =>
      expect(postCollectorAction).toHaveBeenCalledWith('box-drain', {
        args: { host: 'debian1', expectedRevision: '4205' },
        requestedBy: 'overdeck-web',
      }),
    )
  })

  it('retains selected host when the detail modal closes', async () => {
    renderCiContent()
    await selectDebian1()

    fireEvent.click(screen.getByLabelText('Close'))
    await waitFor(() => expect(screen.queryByTestId('machine-detail-modal')).not.toBeInTheDocument())
    expect(screen.getByTestId('offload-selected-host')).toHaveTextContent('selected host: debian1')
  })

  it('posts exact args for control and modal verbs with requestedBy overdeck-web', async () => {
    renderCiContent()
    await selectDebian1()
    fireEvent.click(screen.getByLabelText('Close'))

    await openConfirmFromControl('Drain box')
    confirmAction('Drain box')
    await waitFor(() =>
      expect(postCollectorAction).toHaveBeenCalledWith('box-drain', {
        args: { host: 'debian1', expectedRevision: '4192' },
        requestedBy: 'overdeck-web',
      }),
    )

    vi.mocked(postCollectorAction).mockClear()
    await selectDebian1()
    fireEvent.click(within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Reconcile dispatch' }))
    await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
    confirmAction('Reconcile dispatch')
    await waitFor(() =>
      expect(postCollectorAction).toHaveBeenCalledWith('admission-reconcile', {
        args: { expectedRevision: '4192' },
        requestedBy: 'overdeck-web',
      }),
    )

    vi.mocked(postCollectorAction).mockClear()
    fireEvent.click(within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Quarantine' }))
    await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
    confirmAction('Quarantine')
    await waitFor(() =>
      expect(postCollectorAction).toHaveBeenCalledWith('host-quarantine', {
        args: {
          host: 'debian1',
          command: 'playwright browsers',
          expectedRevision: '4192',
        },
        requestedBy: 'overdeck-web',
      }),
    )

    vi.mocked(postCollectorAction).mockClear()
    fireEvent.click(within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Logs' }))
    await waitFor(() => expect(screen.getByTestId('host-logs-modal')).toBeInTheDocument())
    expect(screen.queryByTestId('action-confirm')).not.toBeInTheDocument()
    expect(fetchHostLogs).toHaveBeenCalledWith('debian1')
    expect(screen.getByTestId('host-logs-body')).toHaveTextContent('builder ready')
    expect(postCollectorAction).not.toHaveBeenCalled()
  })

  it('does not post when confirm is cancelled or verb is unsupported', async () => {
    renderCiContent()
    await selectDebian1()

    fireEvent.click(within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Drain' }))
    await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
    fireEvent.click(within(screen.getByTestId('action-confirm')).getByRole('button', { name: 'Cancel' }))
    expect(postCollectorAction).not.toHaveBeenCalled()

    expect(buildOffloadActionArgs('job-retry', 4192, DEBIAN1)).toBeNull()
  })

  it('refetches collector-state after a successful action', async () => {
    const { queryClient } = renderCiContent()
    const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
    await selectDebian1()

    fireEvent.click(within(screen.getByTestId('machine-detail-modal')).getByRole('button', { name: 'Drain' }))
    await waitFor(() => expect(screen.getByTestId('action-confirm')).toBeInTheDocument())
    confirmAction('Drain')

    await waitFor(() => expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['collector-state'] }))
  })

  it('preserves selection, surfaces alert + danger toast, and retries after failure', async () => {
    vi.mocked(postCollectorAction)
      .mockRejectedValueOnce(new Error('409 stale revision'))
      .mockResolvedValueOnce({ ok: true, result: 'restored' })

    renderCiContent()
    await selectDebian1()
    fireEvent.click(screen.getByLabelText('Close'))

    await openConfirmFromControl('Restore box')
    confirmAction('Restore box')

    await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('409 stale revision'))
    expect(mockToast).toHaveBeenCalledWith(
      expect.objectContaining({ title: 'Restore box failed', tone: 'danger' }),
    )
    expect(screen.getByTestId('offload-selected-host')).toHaveTextContent('selected host: debian1')
    await waitFor(() => expect(screen.getByRole('button', { name: 'Restore box' })).not.toHaveAttribute('aria-busy'))

    await openConfirmFromControl('Restore box')
    confirmAction('Restore box')

    await waitFor(() =>
      expect(postCollectorAction).toHaveBeenLastCalledWith('box-restore', {
        args: { host: 'debian1', expectedRevision: '4192' },
        requestedBy: 'overdeck-web',
      }),
    )
    expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Restore box succeeded', tone: 'success' }))
  })
  it('lists a registry host the controller never enrolled, and says so on the card', async () => {
    const declaredOnly = {
      ...DEBIAN1,
      host: 'debian3',
      enrolled: false,
      state: null,
      registryState: 'reachable' as const,
      load: null,
      cores: null,
      running: null,
      slotsFree: null,
      slotsTotal: null,
      builds24h: null,
      sessions: null,
      dispatchAccept: null,
      capability: { probes: [] },
    }
    const state = stateResponse()
    state.panels = state.panels.map((panel) =>
      panel.id === 'fleet'
        ? { ...panel, data: { ...(panel.data as object), hosts: [DEBIAN1, declaredOnly] } }
        : panel,
    )
    vi.mocked(fetchCollectorState).mockResolvedValue(state)

    renderCiContent()
    const card = await screen.findByTestId('machine-card-debian3')
    expect(card).toHaveTextContent('not enrolled')

    fireEvent.click(card)
    await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('debian3'))
    expect(fetchHostLogs).not.toHaveBeenCalledWith('debian3')
  })

  it('does not call an unreachable controller evidence of enrollment', async () => {
    const unobserved = (host: FleetHost): FleetHost => ({
      ...host,
      enrolled: null,
      state: null,
      load: null,
      cores: null,
    })
    const state = stateResponse()
    state.panels = state.panels.map((panel) =>
      panel.id === 'fleet'
        ? {
            ...panel,
            data: { ...(panel.data as object), stale: true, hosts: [unobserved(DEBIAN1)] },
          }
        : panel,
    )
    vi.mocked(fetchCollectorState).mockResolvedValue(state)

    renderCiContent()
    const card = await screen.findByTestId('machine-card-debian1')
    expect(card).not.toHaveTextContent('not enrolled')
    expect(screen.getByTestId('ci-build-fleet').parentElement).toHaveTextContent(
      'builders online unknown',
    )
  })

  it('says the registry is unreadable rather than presenting a shorter fleet as complete', async () => {
    const state = stateResponse()
    state.panels = state.panels.map((panel) =>
      panel.id === 'fleet'
        ? { ...panel, data: { ...(panel.data as object), registryError: 'buildbox registry unavailable: no such file' } }
        : panel,
    )
    vi.mocked(fetchCollectorState).mockResolvedValue(state)

    renderCiContent()
    await waitFor(() =>
      expect(
        screen.getAllByRole('status').some((node) => node.textContent?.includes('Buildbox registry unreadable')),
      ).toBe(true),
    )
  })
})
