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 { HookFireModuleRow, HookFireStatsResponse, HookInventoryItem, HookInventoryResponse } from '../../lib/collector-types'
import { fetchHookControls, fetchHookFireStats, fetchHookInventory, repairHookControls, setHookControl } from '../../lib/collector-client'
import { HooksContent } from './HooksContent'

vi.mock('../../lib/collector-client', async () => {
  const actual = await vi.importActual<typeof import('../../lib/collector-client')>(
    '../../lib/collector-client',
  )
  return {
    ...actual,
    fetchHookInventory: vi.fn(),
    fetchHookFireStats: vi.fn(),
    fetchHookControls: vi.fn(),
    setHookControl: vi.fn(),
    repairHookControls: vi.fn(),
  }
})

function hook(overrides: Partial<HookInventoryItem> & { id: string }): HookInventoryItem {
  return {
    source: '/home/user/.claude/settings.json',
    scope: 'user',
    event: 'PreToolUse',
    matcher: 'Bash',
    command: '/home/user/.claude/hooks/gate.sh',
    commandPath: '/home/user/.claude/hooks/gate.sh',
    exists: true,
    executable: true,
    runtime: 'sh',
    runtimeResolves: true,
    status: 'OK',
    problem: null,
    lastExecutionAt: null,
    evidenceSource: null,
    ...overrides,
  }
}

function response(hooks: HookInventoryItem[], sourceErrors: HookInventoryResponse['sourceErrors'] = []): HookInventoryResponse {
  return {
    generatedAt: '2026-08-08T00:00:00.000Z',
    sources: ['/home/user/.claude/settings.json'],
    sourceErrors,
    hooks,
  }
}

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

function fireStatsResponse(modules: HookFireModuleRow[], overrides: Partial<HookFireStatsResponse> = {}): HookFireStatsResponse {
  return {
    generatedAt: '2026-08-15T12:00:00.000Z',
    path: '/home/user/.local/state/overdeck/hook-fires.jsonl',
    status: 'ok',
    earliestObservedAt: '2026-08-08T12:00:00.000Z',
    daysObserved: 7,
    modules,
    ...overrides,
  }
}

function fireRow(overrides: Partial<HookFireModuleRow> & { module: string }): HookFireModuleRow {
  return {
    event: 'PreToolUse',
    matcher: 'Bash',
    inManifest: true,
    fires24h: 0,
    fires7d: 0,
    fires30d: 0,
    lastFiredAt: null,
    status: 'OBSERVING',
    ...overrides,
  }
}

const inventoryMock = vi.mocked(fetchHookInventory)
const fireStatsMock = vi.mocked(fetchHookFireStats)
const controlsMock = vi.mocked(fetchHookControls)
const setControlMock = vi.mocked(setHookControl)
const repairMock = vi.mocked(repairHookControls)

describe('HooksContent', () => {
  beforeEach(() => {
    vi.resetAllMocks()
    controlsMock.mockResolvedValue({ controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, issue: null })
    fireStatsMock.mockResolvedValue(fireStatsResponse([]))
  })

  afterEach(() => {
    cleanup()
  })

  it('places the fetched control before hook counts', async () => {
    inventoryMock.mockResolvedValue(response([]))
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    await waitFor(() => expect(toggle).not.toBeDisabled())
    expect(toggle).toBeChecked()
    expect(toggle.compareDocumentPosition(screen.getByText('0 hooks')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
  })

  it('disables the control before the initial GET resolves and during mutation', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockImplementation(() => new Promise(() => undefined))
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).toBeDisabled()
  })

  it('retains confirmed state and surfaces mutation errors', async () => {
    inventoryMock.mockResolvedValue(response([]))
    setControlMock.mockRejectedValue(new Error('Write failed'))
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    await waitFor(() => expect(toggle).not.toBeDisabled())
    fireEvent.click(toggle)
    expect((await screen.findAllByText('Write failed')).length).toBeGreaterThan(0)
    expect(toggle).toBeChecked()
  })


  it('adopts observed state and offers reconfirm when persistence is indeterminate', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
      persistence: 'indeterminate',
      persistenceDetail: 'parent directory fsync failed after commit',
    })
    setControlMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
    })
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).not.toBeChecked()
    expect(toggle).toBeDisabled()
    expect((await screen.findAllByText('parent directory fsync failed after commit')).length).toBeGreaterThan(0)
    fireEvent.click(await screen.findByRole('button', { name: 'Reconfirm hook controls' }))
    await waitFor(() => expect(setControlMock).toHaveBeenCalledWith('background-jobs-blocker', false))
  })

  it('retains observed state and surfaces reconfirm errors', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
      persistence: 'indeterminate',
      persistenceDetail: 'parent directory fsync failed after commit',
    })
    setControlMock.mockRejectedValue(new Error('Reconfirm failed'))
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).not.toBeChecked()
    fireEvent.click(await screen.findByRole('button', { name: 'Reconfirm hook controls' }))
    expect((await screen.findAllByText('Reconfirm failed')).length).toBeGreaterThan(0)
    expect(toggle).not.toBeChecked()
  })

  it('disables the toggle and offers repair when the config issue is non-null', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } },
      issue: { code: 'invalid-hook-controls', detail: 'hook-controls.json is not valid JSON', repairable: true },
    })
    repairMock.mockResolvedValue({ controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, issue: null })
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).toBeDisabled()
    fireEvent.click(await screen.findByRole('button', { name: 'Repair hook controls' }))
    await waitFor(() => expect(repairMock).toHaveBeenCalled())
  })

  it('groups hooks by event and flags dead ones', async () => {
    inventoryMock.mockResolvedValue(response([
      hook({ id: 'a', event: 'PreToolUse' }),
      hook({
        id: 'b',
        event: 'Stop',
        matcher: null,
        command: '/home/user/.claude/hooks/gone.sh',
        commandPath: '/home/user/.claude/hooks/gone.sh',
        exists: false,
        executable: false,
        runtime: null,
        runtimeResolves: null,
        status: 'DEAD',
        problem: 'hook target is missing',
      }),
    ]))
    renderContent()

    const preToolUse = within(await screen.findByTestId('hooks-table-PreToolUse'))
    expect(preToolUse.getByText('OK')).toBeInTheDocument()

    const stop = within(screen.getByTestId('hooks-table-Stop'))
    expect(stop.getByText('DEAD')).toBeInTheDocument()
    expect(stop.getByText('hook target is missing')).toBeInTheDocument()

    expect(screen.getByTestId('hooks-dead-count')).toHaveTextContent('1 dead')
  })

  it('shows unresolved runtimes and the unobserved count', async () => {
    inventoryMock.mockResolvedValue(response([
      hook({
        id: 'a',
        runtime: 'python9',
        runtimeResolves: false,
        status: 'DEAD',
        problem: 'runtime python9 does not resolve to an executable',
      }),
      hook({
        id: 'b',
        event: 'SessionStart',
        matcher: null,
        runtime: null,
        runtimeResolves: null,
        status: 'UNOBSERVED',
        problem: 'hook target is unreadable, so its runtime cannot be verified',
      }),
    ]))
    renderContent()

    expect(await screen.findByText('python9')).toBeInTheDocument()
    expect(screen.getByText('UNOBSERVED')).toBeInTheDocument()
    expect(screen.getByTestId('hooks-unobserved-count')).toHaveTextContent('1 unobserved')
  })

  it('filters hooks across events', async () => {
    inventoryMock.mockResolvedValue(response([
      hook({ id: 'a', event: 'PreToolUse', command: '/home/user/.claude/hooks/gate.sh' }),
      hook({ id: 'b', event: 'Stop', matcher: null, command: '/home/user/.claude/hooks/notify.sh' }),
    ]))
    renderContent()

    await screen.findByTestId('hooks-table-PreToolUse')
    fireEvent.change(screen.getByLabelText('Filter hooks'), { target: { value: 'notify' } })

    expect(screen.queryByTestId('hooks-table-PreToolUse')).not.toBeInTheDocument()
    expect(screen.getByTestId('hooks-table-Stop')).toBeInTheDocument()
  })


  it('renders unchecked before hook controls load', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockImplementation(() => new Promise(() => undefined))
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).not.toBeChecked()
    expect(toggle).toBeDisabled()
  })



  it('does not offer repair for nonrepairable issues but still surfaces issue detail', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } },
      issue: { code: 'invalid-hook-controls', detail: 'hook-controls.json is not a regular file', repairable: false },
    })
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    expect(toggle).toBeDisabled()
    expect((await screen.findAllByText('hook-controls.json is not a regular file')).length).toBeGreaterThan(0)
    expect(screen.queryByRole('button', { name: 'Repair hook controls' })).not.toBeInTheDocument()
  })

  it('surfaces both issue and persistence detail when both are present', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: { code: 'invalid-hook-controls', detail: 'hook-controls.json is not valid JSON', repairable: true },
      persistence: 'indeterminate',
      persistenceDetail: 'parent directory fsync failed after commit',
    })
    renderContent()
    expect((await screen.findAllByText('hook-controls.json is not valid JSON; parent directory fsync failed after commit')).length).toBeGreaterThan(0)
    expect(screen.queryByRole('button', { name: 'Reconfirm hook controls' })).not.toBeInTheDocument()
  })
  it('does not show reconfirm when config issue is non-null', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: { code: 'invalid-hook-controls', detail: 'hook-controls.json is not valid JSON', repairable: true },
      persistence: 'indeterminate',
      persistenceDetail: 'parent directory fsync failed after commit',
    })
    renderContent()
    await screen.findByLabelText('Block agent background jobs')
    expect((await screen.findAllByText('hook-controls.json is not valid JSON; parent directory fsync failed after commit')).length).toBeGreaterThan(0)
    expect(screen.queryByRole('button', { name: 'Reconfirm hook controls' })).not.toBeInTheDocument()
    expect(screen.getByRole('button', { name: 'Repair hook controls' })).toBeInTheDocument()
  })

  it('mutually excludes repair and reconfirm pending states', async () => {
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
      persistence: 'indeterminate',
      persistenceDetail: 'parent directory fsync failed after commit',
    })
    setControlMock.mockImplementation(() => new Promise(() => undefined))
    renderContent()
    const reconfirm = await screen.findByRole('button', { name: 'Reconfirm hook controls' })
    fireEvent.click(reconfirm)
    await waitFor(() => expect(screen.getByRole('button', { name: 'Reconfirm hook controls' })).toBeDisabled())

    controlsMock.mockResolvedValue({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } },
      issue: { code: 'invalid-hook-controls', detail: 'hook-controls.json is not valid JSON', repairable: true },
    })
    repairMock.mockImplementation(() => new Promise(() => undefined))
    cleanup()
    renderContent()
    const repair = await screen.findByRole('button', { name: 'Repair hook controls' })
    fireEvent.click(repair)
    await waitFor(() => expect(screen.getByRole('button', { name: 'Repair hook controls' })).toBeDisabled())
  })

  it('clears stale mutation error after successful toggle and confirmed GET', async () => {
    inventoryMock.mockResolvedValue(response([]))
    setControlMock.mockRejectedValueOnce(new Error('Write failed'))
    controlsMock.mockResolvedValueOnce({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } },
      issue: null,
    })
    renderContent()
    const toggle = await screen.findByLabelText('Block agent background jobs')
    await waitFor(() => expect(toggle).not.toBeDisabled())
    fireEvent.click(toggle)
    expect((await screen.findAllByText('Write failed')).length).toBeGreaterThan(0)
    setControlMock.mockResolvedValueOnce({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
    })
    controlsMock.mockResolvedValueOnce({
      controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': false } },
      issue: null,
    })
    fireEvent.click(toggle)
    await waitFor(() => expect(screen.queryByText('Write failed')).not.toBeInTheDocument())
  })

  it('renders the empty state and source errors', async () => {
    inventoryMock.mockResolvedValue(response([], [{ source: '/home/user/.claude/tools.json', error: 'Unexpected token' }]))
    renderContent()

    expect(await screen.findByTestId('hooks-empty')).toHaveTextContent('No hooks configured')
    expect(screen.getByTestId('hooks-source-errors')).toHaveTextContent('/home/user/.claude/tools.json')
  })
})

describe('HooksContent hook fire telemetry', () => {
  beforeEach(() => {
    vi.resetAllMocks()
    inventoryMock.mockResolvedValue(response([]))
    controlsMock.mockResolvedValue({ controls: { version: 'hook-controls/v1', hooks: { 'background-jobs-blocker': true } }, issue: null })
  })

  afterEach(() => {
    cleanup()
  })

  it('shows "no data yet" and zeroed manifest rows when the fire log has never been written', async () => {
    fireStatsMock.mockResolvedValue(
      fireStatsResponse(
        [fireRow({ module: 'curl-timeout-gate', status: 'OBSERVING' })],
        { status: 'absent', daysObserved: 0, earliestObservedAt: null },
      ),
    )
    renderContent()

    expect(await screen.findByTestId('hook-fires-observed')).toHaveTextContent('no data yet')
    const table = within(screen.getByTestId('hook-fires-table'))
    expect(table.getByText('curl-timeout-gate')).toBeInTheDocument()
    expect(table.getByText('observing (day 0)')).toBeInTheDocument()
  })

  it('marks a module ACTIVE when it fired in the last 7 days', async () => {
    fireStatsMock.mockResolvedValue(
      fireStatsResponse([
        fireRow({ module: 'curl-timeout-gate', status: 'ACTIVE', fires24h: 3, fires7d: 5, fires30d: 5, lastFiredAt: '2026-08-15T11:00:00.000Z' }),
      ]),
    )
    renderContent()

    const table = within(await screen.findByTestId('hook-fires-table'))
    expect(table.getByText('ACTIVE')).toBeInTheDocument()
  })

  it('marks a module SUSPECT after 7 observed days with zero fires', async () => {
    fireStatsMock.mockResolvedValue(
      fireStatsResponse([fireRow({ module: 'security-plugin', status: 'SUSPECT' })], { daysObserved: 9 }),
    )
    renderContent()

    const table = within(await screen.findByTestId('hook-fires-table'))
    expect(table.getByText('SUSPECT')).toBeInTheDocument()
    expect(screen.getByTestId('hook-fires-suspect-count')).toHaveTextContent('1 suspect')
  })

  it('marks a module REMOVAL CANDIDATE after 30 observed days with zero fires', async () => {
    fireStatsMock.mockResolvedValue(
      fireStatsResponse([fireRow({ module: 'security-plugin', status: 'REMOVAL_CANDIDATE' })], { daysObserved: 31 }),
    )
    renderContent()

    const table = within(await screen.findByTestId('hook-fires-table'))
    expect(table.getByText('REMOVAL CANDIDATE')).toBeInTheDocument()
    expect(screen.getByTestId('hook-fires-removal-count')).toHaveTextContent('1 removal candidate')
  })

  it('includes a module observed in telemetry but absent from the manifest', async () => {
    fireStatsMock.mockResolvedValue(
      fireStatsResponse([
        fireRow({ module: 'session-summary', event: 'Stop', matcher: null, inManifest: false, status: 'ACTIVE', fires7d: 1, fires24h: 1, fires30d: 1, lastFiredAt: '2026-08-15T11:00:00.000Z' }),
      ]),
    )
    renderContent()

    const table = within(await screen.findByTestId('hook-fires-table'))
    expect(table.getByText('session-summary')).toBeInTheDocument()
    expect(table.getByText('Stop')).toBeInTheDocument()
  })
})
