import { describe, expect, it } from 'vitest'
import { CollectorHttpError } from '../../lib/collector-client'
import type { Incident, IncidentPriority, IncidentState, IncidentTypeOption } from '../../lib/incident-types'
import {
  EM_DASH,
  cliAndModel,
  dispatchFailure,
  incidentKvRows,
  incidentStatusToken,
  incidentTimestamp,
  matchesIncidentFilter,
  priorityRank,
  sortIncidents,
  suggestIncidentType,
  typeFilingOptions,
} from './incident-view'

function incident(overrides: Partial<Incident> & { id: string }): Incident {
  return {
    kanboardTaskId: 1,
    title: 'Collector wedged',
    description: 'It stopped emitting deltas.',
    priority: 'P2',
    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: null,
      model: null,
      wrapperModel: null,
      reasoningEffort: null,
      account: null,
      requestSha256: null,
      statusRevision: null,
      startedAt: null,
      heartbeatAt: null,
      completedAt: null,
      exitCode: null,
      failureClass: null,
      resultSummary: null,
      ...overrides.dispatch,
    },
  }
}

describe('sortIncidents', () => {
  it('puts active incidents ahead of resolved ones', () => {
    const sorted = sortIncidents([
      incident({ id: 'done', active: false, priority: 'P0' }),
      incident({ id: 'live', active: true, priority: 'P3' }),
    ])
    expect(sorted.map((row) => row.id)).toEqual(['live', 'done'])
  })

  it('orders P0 before P3 and sorts unknown priority last', () => {
    const sorted = sortIncidents([
      incident({ id: 'unknown', priority: null }),
      incident({ id: 'low', priority: 'P3' }),
      incident({ id: 'critical', priority: 'P0' }),
    ])
    expect(sorted.map((row) => row.id)).toEqual(['critical', 'low', 'unknown'])
  })

  it('breaks priority ties by most recent update, absent updates last', () => {
    const sorted = sortIncidents([
      incident({ id: 'older', updatedAt: '2026-08-08T00:01:00.000Z' }),
      incident({ id: 'never', updatedAt: null }),
      incident({ id: 'newer', updatedAt: '2026-08-08T00:09:00.000Z' }),
    ])
    expect(sorted.map((row) => row.id)).toEqual(['newer', 'older', 'never'])
  })
})

describe('incidentTimestamp', () => {
  it('renders an em dash rather than a fabricated time when the stamp is absent', () => {
    expect(incidentTimestamp(null)).toEqual({ relative: EM_DASH, absolute: null })
  })

  it('renders an em dash for an unparseable stamp', () => {
    expect(incidentTimestamp('not-a-date')).toEqual({ relative: EM_DASH, absolute: null })
  })

  it('pairs a relative label with the absolute ISO instant', () => {
    const now = Date.parse('2026-08-08T01:00:00.000Z')
    expect(incidentTimestamp('2026-08-08T00:00:00.000Z', now)).toEqual({
      relative: '1h ago',
      absolute: '2026-08-08T00:00:00.000Z',
    })
  })
})

describe('matchesIncidentFilter', () => {
  const row = incident({ id: 'INC-7', title: 'Runner offline', dispatch: { cli: 'codex' } as Incident['dispatch'] })

  it('matches on id, title and cli, case-insensitively', () => {
    expect(matchesIncidentFilter(row, 'inc-7')).toBe(true)
    expect(matchesIncidentFilter(row, 'OFFLINE')).toBe(true)
    expect(matchesIncidentFilter(row, 'codex')).toBe(true)
  })

  it('keeps every incident when the filter is blank', () => {
    expect(matchesIncidentFilter(row, '   ')).toBe(true)
  })

  it('excludes non-matching incidents', () => {
    expect(matchesIncidentFilter(row, 'postgres')).toBe(false)
  })
})

describe('cliAndModel', () => {
  it('renders an em dash when neither is recorded', () => {
    expect(cliAndModel(incident({ id: 'a' }))).toBe(EM_DASH)
  })

  it('joins cli and model, and degrades to whichever is known', () => {
    const both = incident({ id: 'a', dispatch: { cli: 'codex', model: 'gpt-5' } as Incident['dispatch'] })
    expect(cliAndModel(both)).toBe('codex · gpt-5')
    const cliOnly = incident({ id: 'b', dispatch: { cli: 'codex' } as Incident['dispatch'] })
    expect(cliAndModel(cliOnly)).toBe('codex')
  })
})

describe('incidentKvRows', () => {
  it('renders unknown fields as em dashes rather than zeroes or blanks', () => {
    const rows = incidentKvRows(incident({ id: 'a', priority: null }))
    const byLabel = new Map(rows.map((row) => [row.label, row.value]))
    expect(byLabel.get('Priority')).toBe(EM_DASH)
    expect(byLabel.get('Exit code')).toBe(EM_DASH)
    expect(byLabel.get('Model')).toBe(EM_DASH)
  })

  it('flags a non-zero exit code and a failure class as errors', () => {
    const rows = incidentKvRows(
      incident({ id: 'a', dispatch: { exitCode: 1, failureClass: 'timeout' } as Incident['dispatch'] }),
    )
    const byLabel = new Map(rows.map((row) => [row.label, row]))
    expect(byLabel.get('Exit code')?.intent).toBe('err')
    expect(byLabel.get('Failure class')?.intent).toBe('err')
  })

  it('leaves a clean exit code unflagged', () => {
    const rows = incidentKvRows(incident({ id: 'a', dispatch: { exitCode: 0 } as Incident['dispatch'] }))
    expect(rows.find((row) => row.label === 'Exit code')?.intent).toBeUndefined()
  })
})

describe('incidentStatusToken', () => {
  it('maps every incident state onto a token StatusChip already categorises', () => {
    const states: IncidentState[] = ['filed', 'dispatching', 'running', 'needs-attention', 'resolved']
    const categories = states.map((state) => incidentStatusToken(state))
    expect(categories).toEqual(['queued', 'queued', 'running', 'attention', 'completed'])
  })
})

describe('priorityRank', () => {
  it('ranks P0 highest and leaves unknown priority unranked', () => {
    const ranks: Array<number | null> = (['P0', 'P1', 'P2', 'P3'] as IncidentPriority[]).map(priorityRank)
    expect(ranks).toEqual([0, 1, 2, 3])
    expect(priorityRank(null)).toBeNull()
  })
})

const TYPES: IncidentTypeOption[] = [
  { id: 'hooks-harness', title: 'Hooks / harness', keywords: ['hook', 'harness'] },
  { id: 'resource-overload', title: 'Resource overload', keywords: ['cpu', 'load', 'swap'] },
]

describe('typeFilingOptions', () => {
  it('leads with an explicit None so the field is clearable', () => {
    expect(typeFilingOptions(TYPES)).toEqual([
      { value: '', label: 'None' },
      { value: 'hooks-harness', label: 'Hooks / harness' },
      { value: 'resource-overload', label: 'Resource overload' },
    ])
  })
})

describe('suggestIncidentType', () => {
  it('picks the type with the most distinct keyword hits', () => {
    expect(suggestIncidentType('cpu load pegged', 'swap thrashing too', TYPES)).toBe('resource-overload')
  })

  it('is case-insensitive and matches across title and description', () => {
    expect(suggestIncidentType('HOOK broken', 'the Harness died', TYPES)).toBe('hooks-harness')
  })

  it('suggests nothing when no keyword matches', () => {
    expect(suggestIncidentType('disk full', 'no space left', TYPES)).toBeNull()
  })

  it('keeps taxonomy order on a tie', () => {
    expect(suggestIncidentType('hook and cpu', '', TYPES)).toBe('hooks-harness')
  })

  it('ignores empty keyword entries', () => {
    const types: IncidentTypeOption[] = [{ id: 'x', title: 'X', keywords: ['', '  '] }]
    expect(suggestIncidentType('anything', 'at all', types)).toBeNull()
  })
})

describe('dispatchFailure', () => {
  it('surfaces the named brief refusal verbatim and flags the degraded path', () => {
    const error = new CollectorHttpError(
      422,
      { error: 'brief-assembly-failed', detail: 'dispatch blocked: brief assembly failed — missing required asset: taxonomy.json' },
      'blocked',
    )
    expect(dispatchFailure(error)).toEqual({
      message: 'dispatch blocked: brief assembly failed — missing required asset: taxonomy.json',
      briefBlocked: true,
    })
  })

  it('names an unavailable store without offering the degraded path', () => {
    const failure = dispatchFailure(new CollectorHttpError(503, { error: 'incidents-store-unavailable' }, 'boom'))
    expect(failure.briefBlocked).toBe(false)
    expect(failure.message).toContain('store is unavailable')
  })

  it('falls back to a generic message for unknown errors', () => {
    expect(dispatchFailure(new Error('nope'))).toEqual({ message: 'The incident could not be dispatched.', briefBlocked: false })
  })
})
