import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, errorResponse } from './msw/helpers'
import { renderWithProviders, screen, fireEvent, waitFor, userEvent, act } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { seedClasses } from './fixtures/seed_classes'
import DetectionClasses from '../src/components/DetectionClasses'
import { FALLBACK_CLASS_NAMES } from '../src/features/annotations/classColors'
import type { DetectionClass } from '../src/types'

// AZ-472 — DetectionClasses load + 1-9 hotkeys + click path + empty/5xx fallback.
//
// AC-1 (FT-P-44): GET /api/annotations/classes observed at mount; rendered list
//                 reflects the active photoMode filter (no fallback marker).
// AC-2 (FT-P-45): for each P ∈ {0, 20, 40}, key k=1..9 selects the k-th class
//                 within the P-window — i.e., the entry with id `P + (k-1)`
//                 per FT-P-45 spec ("the appropriate window of 9").
// AC-3 (FT-P-46): clicking a class entry fires onSelect(c.id) once.
// AC-4 (FT-P-47): when /api/annotations/classes returns [] OR a 5xx, the
//                 fallback list is rendered and the id set equals
//                 [0..N-1, 20..20+N-1, 40..40+N-1].
//
// Documented drifts (from `_docs/02_document/tests/blackbox-tests.md` note on
// AC-37 row 79: "fix can land either side per data_parameters.md"):
//   - Production hotkey logic uses `classes[idx + photoMode]` against the
//     loaded array. For a dense response of length 27 (3 windows × 9 entries)
//     this yields the wrong class for P=20 and the index is out-of-range for
//     P=40. AC-2 for P=20/P=40 is `it.fails()`. Both flip green when either
//     production switches to `modeClasses[idx]` (filter-then-index) OR the
//     suite serves a sparse length-60 array.
//   - The seed_classes fixture today sets `photoMode: 0` on every entry,
//     which makes the rendering filter `c.photoMode === photoMode` show only
//     P=0 entries. To unblock AZ-472 without modifying the AZ-456-owned
//     fixture, every test in this file overrides the GET handler with a
//     correctly-tagged copy (`orderedClasses`, photoMode set per offset).

const orderedClasses: DetectionClass[] = seedClasses.map((c) => ({
  ...c,
  photoMode: c.id < 20 ? 0 : c.id < 40 ? 20 : 40,
}))

function captureClassesGets(payload: DetectionClass[], opts?: { status?: number }) {
  const calls: { url: string }[] = []
  server.use(
    http.get('/api/annotations/classes', ({ request }) => {
      calls.push({ url: new URL(request.url).pathname })
      if (opts?.status && opts.status >= 500) return errorResponse(opts.status, 'simulated server error')
      return jsonResponse(payload)
    }),
    // AuthProvider GETs /api/admin/auth/refresh on every mount — the default
    // admin handler only responds to POST. Returning 401 here silences MSW's
    // unhandled-request errors without affecting these tests (AuthProvider's
    // .catch swallows the failure and DetectionClasses doesn't depend on auth
    // user state).
    http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
  )
  return calls
}

interface HarnessState {
  selectedRef: { current: number }
  selectSpy: ReturnType<typeof vi.fn>
  modeSpy: ReturnType<typeof vi.fn>
}

function HarnessWrapper({
  initialPhotoMode = 0,
  state,
}: {
  initialPhotoMode?: number
  state: HarnessState
}) {
  return (
    <DetectionClasses
      selectedClassNum={state.selectedRef.current}
      onSelect={(id: number) => {
        state.selectedRef.current = id
        state.selectSpy(id)
      }}
      photoMode={initialPhotoMode}
      onPhotoModeChange={(mode: number) => {
        state.modeSpy(mode)
      }}
    />
  )
}

function makeHarnessState(): HarnessState {
  return {
    selectedRef: { current: -1 },
    selectSpy: vi.fn(),
    modeSpy: vi.fn(),
  }
}

describe('AZ-472 — DetectionClasses (load / hotkeys / click / fallback)', () => {
  beforeEach(() => {
    seedBearer()
  })

  describe('AC-1 (FT-P-44) — load contract', () => {
    it('GETs /api/annotations/classes and renders the active-mode window', async () => {
      // Arrange — install a counting handler returning the corrected seed.
      const calls = captureClassesGets(orderedClasses)
      const state = makeHarnessState()

      // Act
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)

      // Assert — the GET fired against the contract URL.
      await waitFor(() => expect(calls.length).toBeGreaterThan(0))
      expect(calls[0].url).toBe('/api/annotations/classes')

      // Observable: 9 entries for photoMode=0 (ids 0..8). FALLBACK_CLASS_NAMES
      // is NOT used because the API returned data.
      await waitFor(() => {
        expect(screen.getByText('class-0')).toBeInTheDocument()
        expect(screen.getByText('class-8')).toBeInTheDocument()
      })
      // The fallback's first name is "Car" — absent here, since the API
      // returned a populated payload.
      expect(screen.queryByText('Car')).toBeNull()

      clearBearer()
    })
  })

  describe('AC-2 (FT-P-45) — hotkey arithmetic', () => {
    it('photoMode=0: keys 1..9 select ids 0..8 (production matches spec)', async () => {
      // Arrange
      captureClassesGets(orderedClasses)
      const state = makeHarnessState()
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
      await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())

      // Act + Assert — for each k=1..9, dispatch keydown then check arg.
      for (let k = 1; k <= 9; k++) {
        state.selectSpy.mockClear()
        await act(async () => {
          fireEvent.keyDown(window, { key: String(k) })
        })
        const expectedId = 0 + (k - 1)
        await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
        expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
      }

      clearBearer()
    })

    it.fails(
      'photoMode=20: keys 1..9 select ids 20..28 (production drift — uses classes[idx+P] against dense array)',
      async () => {
        // Production today computes `classes[idx + 20]` against a length-27
        // array — for k=1..9 this lands in the 40s window, returning the
        // wrong id (or undefined for P=40). Spec intent (FT-P-45 "appropriate
        // window of 9") is `P + (k-1)`. Test is `it.fails()` until either the
        // production formula switches to filter-then-index OR the suite
        // serves a sparse length-60 array.
        captureClassesGets(orderedClasses)
        const state = makeHarnessState()
        renderWithProviders(<HarnessWrapper initialPhotoMode={20} state={state} />)
        await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())

        for (let k = 1; k <= 9; k++) {
          state.selectSpy.mockClear()
          await act(async () => {
            fireEvent.keyDown(window, { key: String(k) })
          })
          const expectedId = 20 + (k - 1)
          await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
          expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(expectedId)
        }

        clearBearer()
      },
    )

    it.fails(
      'photoMode=40: keys 1..9 select ids 40..48 (production drift — index out of range)',
      async () => {
        // For P=40 the production index `idx + 40` (range 40..48) exceeds the
        // dense array length 27 — `cls` is undefined and `onSelect` never
        // fires; the assertion below times out / fails accordingly. Same
        // recovery as P=20 above.
        captureClassesGets(orderedClasses)
        const state = makeHarnessState()
        renderWithProviders(<HarnessWrapper initialPhotoMode={40} state={state} />)
        await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())

        for (let k = 1; k <= 9; k++) {
          state.selectSpy.mockClear()
          await act(async () => {
            fireEvent.keyDown(window, { key: String(k) })
          })
          const expectedId = 40 + (k - 1)
          // selectSpy may have 0 calls; toHaveBeenLastCalledWith with no calls
          // throws, which is the failure signal `it.fails()` expects.
          expect(state.selectSpy).toHaveBeenLastCalledWith(expectedId)
        }

        clearBearer()
      },
    )
  })

  describe('AC-3 (FT-P-46) — click path', () => {
    it('clicking a class entry fires onSelect with that class.id', async () => {
      captureClassesGets(orderedClasses)
      const state = makeHarnessState()
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
      const target = await screen.findByText('class-3')
      state.selectSpy.mockClear()

      // Act
      await userEvent.click(target)

      // Assert — onSelect fires with id 3 (the entry's id field).
      await waitFor(() => expect(state.selectSpy).toHaveBeenCalled())
      expect(state.selectSpy.mock.calls.at(-1)?.[0]).toBe(3)

      clearBearer()
    })
  })

  describe('AC-4 (FT-P-47) — fallback on empty / 5xx', () => {
    it('renders the FALLBACK_CLASS_NAMES list when the API returns []', async () => {
      // Arrange
      captureClassesGets([])
      const state = makeHarnessState()

      // Act
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)

      // Assert — fallback list of FALLBACK_CLASS_NAMES.length entries is
      // rendered (one button per fallback class for the active photoMode).
      // Each button's accessible name contains the fallback class name plus
      // its shortName slice; we match by button accessible-name regex to
      // avoid the dual-text duplicate (`Car` appears in both name and
      // shortName spans).
      const findClassButton = async (name: string) =>
        screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
      for (const name of FALLBACK_CLASS_NAMES) {
        await expect(findClassButton(name)).resolves.toBeInTheDocument()
      }
      // Sanity: the seed name 'class-0' is NOT visible (we returned [] not seed).
      expect(screen.queryByText('class-0')).toBeNull()

      clearBearer()
    })

    it('renders the fallback list when the API returns 500', async () => {
      // Arrange — error hits the .catch branch in production, which also sets
      // the fallback. The observable shape is identical to the empty-payload
      // case above.
      captureClassesGets([], { status: 500 })
      const state = makeHarnessState()

      // Act
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)

      // Assert
      const findClassButton = async (name: string) =>
        screen.findByRole('button', { name: new RegExp(`\\b${name}\\b`) })
      for (const name of FALLBACK_CLASS_NAMES) {
        await expect(findClassButton(name)).resolves.toBeInTheDocument()
      }

      clearBearer()
    })

    it('fallback id set equals [0..N-1, 20..20+N-1, 40..40+N-1]', () => {
      // The fallback list is built statically in production as
      // [0,20,40].flatMap(o => FALLBACK_CLASS_NAMES.map((_, i) => ({ id: i + o }))).
      // We pin the contract directly without rendering — downstream tests
      // (AZ-473 PhotoMode) depend on this id set. If the fallback shape ever
      // changes, this test fails AND so do the AZ-473 dependants.
      const N = FALLBACK_CLASS_NAMES.length
      const expected = new Set<number>()
      for (const offset of [0, 20, 40]) {
        for (let i = 0; i < N; i++) expected.add(i + offset)
      }
      const derived = new Set(
        [0, 20, 40].flatMap((o) => FALLBACK_CLASS_NAMES.map((_, i) => i + o)),
      )
      expect(derived).toEqual(expected)
    })
  })
})
