import { useEffect, useState } from 'react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate } from './msw/helpers'
import {
  renderWithProviders,
  screen,
  fireEvent,
  waitFor,
  userEvent,
} from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { DetectionClasses, FlightProvider, useFlight } from '../src/components'
import { AnnotationsPage } from '../src/features/annotations'
import { seedFlights } from './fixtures/seed_flights'
import { seedClasses } from './fixtures/seed_classes'
import {
  MediaType,
  MediaStatus,
} from '../src/types'
import type {
  DetectionClass,
  Media,
} from '../src/types'

// AZ-473 — PhotoMode switch + auto-select + yoloId on the wire.
//
// AC-1 (FT-P-48): clicking a PhotoMode button fires `onPhotoModeChange(P)` and
//                 the rendered class list is filtered to entries with
//                 `photoMode === P`. The "context persistence" assertion is
//                 satisfied by the rendered filter (per spec — no direct
//                 context read). Production has no `<PhotoModeContext>`;
//                 photoMode lives as local state in AnnotationsPage and
//                 DatasetPage. The rendered-filter contract still holds.
// AC-2 (FT-P-49): switching to a mode where the previously-selected class is
//                 out-of-range fires `onSelect(modeClasses[0].id)` once.
// AC-3 (FT-P-50): saving an annotation in mode P sends a POST whose
//                 `detections[i].classNum == classId + P` for every detection.
//                 We exercise all three modes (P ∈ {0, 20, 40}).
//
// Notes on the seed_classes fixture:
//   - The shared fixture sets `photoMode: 0` on every entry, which would
//     break the rendered-filter assertion for P=20 / P=40. AZ-472 already
//     overrides the GET handler with a correctly-tagged copy. We do the
//     same here.

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

// ---------------------------------------------------------------------------
// AC-1 + AC-2 — DetectionClasses-only contracts.
// ---------------------------------------------------------------------------

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

function HarnessWrapper({
  initialPhotoMode = 0,
  initialSelectedClassNum = 0,
  state,
}: {
  initialPhotoMode?: number
  initialSelectedClassNum?: number
  state: HarnessState
}) {
  const [photoMode, setPhotoMode] = useState(initialPhotoMode)
  const [selectedClassNum, setSelectedClassNum] = useState(initialSelectedClassNum)
  // Sync the external selectedRef so tests can read the latest selection
  // without going through the spy's mock.calls (which loses ordering after
  // multiple effects).
  useEffect(() => {
    state.selectedRef.current = selectedClassNum
  }, [selectedClassNum, state])
  return (
    <DetectionClasses
      selectedClassNum={selectedClassNum}
      onSelect={(id) => {
        state.selectSpy(id)
        setSelectedClassNum(id)
      }}
      photoMode={photoMode}
      onPhotoModeChange={(mode) => {
        state.modeSpy(mode)
        setPhotoMode(mode)
      }}
    />
  )
}

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

function captureClassesGet(payload: DetectionClass[]) {
  server.use(
    http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
    http.get('/api/annotations/classes', () => jsonResponse(payload)),
  )
}

describe('AZ-473 — AC-1 + AC-2 (DetectionClasses)', () => {
  beforeEach(() => {
    seedBearer()
    captureClassesGet(orderedClasses)
  })
  afterEach(() => {
    clearBearer()
  })

  describe('AC-1 (FT-P-48) — switch sets filter', () => {
    it('clicking Winter fires onPhotoModeChange(20) and filters the rendered class list to photoMode=20 entries', async () => {
      // Arrange — render with photoMode=0; expect class-0..class-8 visible.
      const state = makeHarnessState()
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
      await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
      expect(screen.queryByText('class-20')).toBeNull()

      // Act — click the Winter button (title = i18n "Winter").
      const winterBtn = screen.getByRole('button', { name: /winter/i })
      await userEvent.click(winterBtn)

      // Assert — onPhotoModeChange fires once with 20.
      await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(20))
      expect(state.modeSpy).toHaveBeenCalledTimes(1)

      // Rendered list switches to the photoMode=20 window
      // (ids 20..28 in orderedClasses).
      await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
      expect(screen.getByText('class-28')).toBeInTheDocument()
      expect(screen.queryByText('class-0')).toBeNull()
      expect(screen.queryByText('class-40')).toBeNull()
    })

    it('clicking Night fires onPhotoModeChange(40) and shows the photoMode=40 window', async () => {
      const state = makeHarnessState()
      renderWithProviders(<HarnessWrapper initialPhotoMode={0} state={state} />)
      await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())

      const nightBtn = screen.getByRole('button', { name: /night/i })
      await userEvent.click(nightBtn)

      await waitFor(() => expect(state.modeSpy).toHaveBeenCalledWith(40))
      await waitFor(() => expect(screen.getByText('class-40')).toBeInTheDocument())
      expect(screen.getByText('class-48')).toBeInTheDocument()
      expect(screen.queryByText('class-0')).toBeNull()
    })
  })

  describe('AC-2 (FT-P-49) — auto-select when prior class is out-of-range', () => {
    it('switching to Night auto-selects modeClasses[0].id (= 40) when selectedClassNum=0 is not in the new window', async () => {
      // Arrange — preselect a Regular class (id=0).
      const state = makeHarnessState()
      renderWithProviders(
        <HarnessWrapper
          initialPhotoMode={0}
          initialSelectedClassNum={0}
          state={state}
        />,
      )
      // The auto-select effect fires on mount — first onSelect is the
      // initialisation. Clear the spy after that mounts so we observe only
      // the post-mode-switch firing.
      await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
      state.selectSpy.mockClear()

      // Act — switch to Night.
      const nightBtn = screen.getByRole('button', { name: /night/i })
      await userEvent.click(nightBtn)

      // Assert — onSelect(40) fires (the first id in the photoMode=40 window).
      await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(40))
    })

    it('switching to Winter (P=20) auto-selects id=20 when prior selection (id=0) is out of range', async () => {
      const state = makeHarnessState()
      renderWithProviders(
        <HarnessWrapper
          initialPhotoMode={0}
          initialSelectedClassNum={0}
          state={state}
        />,
      )
      await waitFor(() => expect(screen.getByText('class-0')).toBeInTheDocument())
      state.selectSpy.mockClear()

      const winterBtn = screen.getByRole('button', { name: /winter/i })
      await userEvent.click(winterBtn)

      await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(20))
    })

    it('does NOT auto-select when the current class is already in the new window', async () => {
      // Pre-select class with id=20 (in the Winter window).
      const state = makeHarnessState()
      renderWithProviders(
        <HarnessWrapper
          initialPhotoMode={20}
          initialSelectedClassNum={20}
          state={state}
        />,
      )
      await waitFor(() => expect(screen.getByText('class-20')).toBeInTheDocument())
      // The on-mount auto-select MAY still fire once if the in-mount sync is
      // racy; reset the spy to observe only the deliberate switch path.
      state.selectSpy.mockClear()

      // Act — switch from Winter→Winter is a noop, but switching to
      // Regular and back to Winter while the in-window class stays
      // out-of-range for Regular triggers an auto-select to 0, then to 20
      // again. We check that a class IS still in-range for the destination
      // window — i.e., switching back to a window where the prior class is
      // valid does NOT regenerate a selection.
      const regularBtn = screen.getByRole('button', { name: /regular/i })
      await userEvent.click(regularBtn)
      await waitFor(() => expect(state.selectSpy).toHaveBeenCalledWith(0))
      state.selectSpy.mockClear()

      // Now we're at photoMode=0, selectedClassNum=0 (in-window). Switching
      // back to Regular is a noop — no auto-select fires.
      await userEvent.click(regularBtn)
      // Allow the effect to flush.
      await new Promise((r) => setTimeout(r, 50))
      expect(state.selectSpy).not.toHaveBeenCalled()
    })
  })
})

// ---------------------------------------------------------------------------
// AC-3 — wire offset on AnnotationsPage save.
// ---------------------------------------------------------------------------

const FLIGHT = seedFlights[0]
const W = 640
const H = 480

const videoMedia: Media = {
  id: 'media-az473',
  name: 'photo-mode-fixture.mp4',
  path: '/media/photo-mode-fixture.mp4',
  mediaType: MediaType.Video,
  mediaStatus: MediaStatus.New,
  duration: '00:00:10',
  annotationCount: 0,
  waypointId: null,
  userId: 'user-az473',
}

interface PostCapture {
  classNums: number[][]
}

function rigSaveEnv(): PostCapture {
  const classNums: number[][] = []
  server.use(
    http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
    http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
    http.get('/api/flights/:id', ({ params }) => {
      const f = seedFlights.find((x) => x.id === params.id)
      return f ? jsonResponse(f) : new Response(null, { status: 404 })
    }),
    http.get('/api/annotations/settings/user', () =>
      jsonResponse({
        id: 'user-settings-az473',
        userId: 'user-az473',
        selectedFlightId: FLIGHT.id,
        annotationsLeftPanelWidth: null,
        annotationsRightPanelWidth: null,
        datasetLeftPanelWidth: null,
        datasetRightPanelWidth: null,
      }),
    ),
    http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
    http.get('/api/annotations/media', () =>
      jsonResponse(paginate([videoMedia], 1, 1000)),
    ),
    http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
    http.get('/api/annotations/classes', () => jsonResponse(orderedClasses)),
    http.get('/api/annotations/dataset/info', () =>
      jsonResponse({ totalCount: 0, statusCounts: {} }),
    ),
    http.post('/api/annotations/annotations', async ({ request }) => {
      const body = (await request.json()) as { detections?: { classNum: number }[] }
      classNums.push((body.detections ?? []).map((d) => d.classNum))
      return jsonResponse({ id: 'ann-saved' })
    }),
  )
  return { classNums }
}

function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
  const { selectFlight, selectedFlight } = useFlight()
  useEffect(() => {
    if (!selectedFlight) selectFlight(FLIGHT)
  }, [selectFlight, selectedFlight])
  return <>{children}</>
}

describe('AZ-473 — AC-3 (wire offset on AnnotationsPage save)', () => {
  let originalRaf: typeof globalThis.requestAnimationFrame
  let widthDescriptor: PropertyDescriptor | undefined
  let heightDescriptor: PropertyDescriptor | undefined
  let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect

  beforeEach(() => {
    seedBearer()
    // Same canvas-coord scaffold as AZ-471 — see canvas_editor.test.tsx for
    // the rationale on each shim.
    widthDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
    heightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
    Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
      configurable: true,
      get() { return W },
    })
    Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
      configurable: true,
      get() { return H },
    })
    originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
    Element.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
      if (this instanceof HTMLCanvasElement) {
        return {
          x: 0, y: 0, left: 0, top: 0,
          right: W, bottom: H, width: W, height: H,
          toJSON() { return {} },
        } as DOMRect
      }
      return originalGetBoundingClientRect.call(this)
    }
    originalRaf = globalThis.requestAnimationFrame
    globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
      cb(performance.now())
      return 0
    }) as typeof globalThis.requestAnimationFrame
    // Force getContext to return a non-null canvas context so draw() doesn't
    // bail. Production checks `if (!canvas || !ctx) return`; jsdom's default
    // is `null`, which would short-circuit the draw and starve the spy.
    HTMLCanvasElement.prototype.getContext = vi.fn(
      () =>
        ({
          clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(),
          drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(),
          fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(),
          setLineDash: vi.fn(),
          measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
          fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1,
        }) as unknown as CanvasRenderingContext2D,
    ) as unknown as typeof HTMLCanvasElement.prototype.getContext
  })

  afterEach(() => {
    clearBearer()
    globalThis.requestAnimationFrame = originalRaf
    Element.prototype.getBoundingClientRect = originalGetBoundingClientRect
    if (widthDescriptor) {
      Object.defineProperty(HTMLElement.prototype, 'clientWidth', widthDescriptor)
    } else {
      delete (HTMLElement.prototype as unknown as { clientWidth?: number }).clientWidth
    }
    if (heightDescriptor) {
      Object.defineProperty(HTMLElement.prototype, 'clientHeight', heightDescriptor)
    } else {
      delete (HTMLElement.prototype as unknown as { clientHeight?: number }).clientHeight
    }
  })

  // Each value of P maps to: photoMode → button label → expected first
  // class id (which equals 0 + P). The test draws ONE box and asserts the
  // POST body's detections[0].classNum == P.
  const cases: { mode: number; label: RegExp }[] = [
    { mode: 0, label: /regular/i },
    { mode: 20, label: /winter/i },
    { mode: 40, label: /night/i },
  ]

  cases.forEach(({ mode, label }) => {
    it(`P=${mode}: saved detection carries classNum == ${mode} (= 0 + ${mode})`, async () => {
      // Arrange
      const cap = rigSaveEnv()
      renderWithProviders(
        <FlightProvider>
          <FlightSeed>
            <AnnotationsPage />
          </FlightSeed>
        </FlightProvider>,
      )

      // Wait for the media list to render and click on the seeded video.
      const mediaItem = await screen.findByText(/photo-mode-fixture\.mp4/)
      await userEvent.click(mediaItem)

      // Wait for the canvas to mount.
      const canvas = await waitFor(() => {
        const c = document.querySelector('canvas')
        if (!c) throw new Error('canvas not yet mounted')
        return c as HTMLCanvasElement
      })

      // Switch PhotoMode (this also triggers the auto-select effect, which
      // fires onSelect with `modeClasses[0].id` = 0 + mode).
      if (mode !== 0) {
        const modeBtn = await screen.findByRole('button', { name: label })
        await userEvent.click(modeBtn)
      }

      // Auto-select must have settled before we draw — otherwise the
      // detection inherits the previous selectedClassNum.
      await waitFor(() => {
        // We can't read selectedClassNum directly. Instead, draw a tiny
        // probe box and check the most-recent detection's classNum.
        // We'll do the actual draw below; this gate just allows the React
        // state-update queue (mode → onSelect → setState) to drain.
        // A short sleep is sufficient for jsdom.
      })
      await new Promise((r) => setTimeout(r, 30))

      // Draw a bbox at (40, 40) → (120, 100). Plain left-click on empty
      // area triggers the production "draw" path.
      fireEvent.mouseDown(canvas, { clientX: 40, clientY: 40, button: 0 })
      fireEvent.mouseMove(canvas, { clientX: 120, clientY: 100, button: 0 })
      fireEvent.mouseUp(canvas, { clientX: 120, clientY: 100, button: 0 })

      // Click Save (the button label is "Save", not gated by i18n today).
      const saveBtn = await screen.findByRole('button', { name: /^save$/i })
      // The Save button is `disabled={!detections.length}` — wait for it.
      await waitFor(() => expect(saveBtn).not.toBeDisabled(), { timeout: 2000 })
      await userEvent.click(saveBtn)

      // Assert — POST observed with detections[0].classNum == mode.
      await waitFor(() => expect(cap.classNums.length).toBeGreaterThan(0), {
        timeout: 3000,
      })
      const last = cap.classNums.at(-1) as number[]
      expect(last).toHaveLength(1)
      expect(last[0]).toBe(mode)
    })
  })
})
