import { describe, it, expect, beforeEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate, sse } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { FlightProvider } from '../src/components'
import { AnnotationsPage } from '../src/features/annotations'
import { MediaType, MediaStatus } from '../src/types'
import type { Media } from '../src/types'

// AZ-461 — Detection endpoints (sync image / async video / long-video header).
//
// AC-1 (FT-P-11): clicking Detect on an image issues exactly one POST whose
//                 URL matches `^/api/detect/[0-9]+$` (the wire contract). The
//                 production handler in <AnnotationsSidebar> already POSTs
//                 `/api/detect/${media.id}` against the active media — passes
//                 today when the media id is numeric.
// AC-2 (FT-P-12): async-video detect endpoint + SSE — TARGET (Phase B). The
//                 async path does not exist in production today (single
//                 Detect button POSTs the same endpoint regardless of media
//                 type; no `/api/detect/video/<id>` route, no `jobId`, no
//                 EventSource on `/api/detect/stream/<id>`). Recorded as
//                 `it.fails()` so the test runs in CI (the spec requires
//                 "test code itself runs (does not just xit)") and emits a
//                 console log "FT-P-12 awaits AC-25 / async video detect impl"
//                 per AC-2 contract. Flips green when AC-25 lands.
// AC-3 (FT-P-13): long-video detect carries an `X-Refresh-Token` header — no
//                 such header is added in production (`api.post` only sets
//                 Authorization + Content-Type). `it.fails()` until the
//                 header is wired in Phase B per task spec note.

// Production detect URL is `/api/detect/<media.id>`. The contract regex
// `^/api/detect/[0-9]+$` requires a numeric id segment; the seed media for
// this test uses a numeric-style string id ('42') so the regex matches the
// observed URL today. (Other tests use 'media-1' style ids for unrelated
// reasons.)
const NUMERIC_MEDIA_ID = '42'
const NUMERIC_VIDEO_MEDIA_ID = '57'

const seedImageMedia: Media = {
  id: NUMERIC_MEDIA_ID,
  name: 'detect-image.jpg',
  path: '/media/detect-image.jpg',
  mediaType: MediaType.Image,
  mediaStatus: MediaStatus.New,
  duration: null,
  annotationCount: 0,
  waypointId: null,
  userId: 'user-az461',
}

const seedVideoMedia: Media = {
  id: NUMERIC_VIDEO_MEDIA_ID,
  name: 'detect-video.mp4',
  path: '/media/detect-video.mp4',
  mediaType: MediaType.Video,
  mediaStatus: MediaStatus.New,
  duration: '00:01:30',
  annotationCount: 0,
  waypointId: null,
  userId: 'user-az461',
}

interface CapturedRequest {
  url: string
  method: string
  pathname: string
  headers: Record<string, string>
}

interface CapturedSSE {
  url: string
}

function captureDetectAndBootstrap(opts?: {
  mediaItems?: Media[]
  detectStatus?: number
  detectResponse?: Record<string, unknown>
  registerVideoEndpoints?: boolean
}): { detectCalls: CapturedRequest[]; sseOpens: CapturedSSE[] } {
  const detectCalls: CapturedRequest[] = []
  const sseOpens: CapturedSSE[] = []
  const items = opts?.mediaItems ?? [seedImageMedia]
  const detectStatus = opts?.detectStatus ?? 200
  const detectResponse = opts?.detectResponse ?? { detections: [] }

  const handlers = [
    // Wide-net detect catcher — production POSTs `/api/detect/<id>` for any
    // media id today. The handler captures URL + headers so AC-1 + AC-3 can
    // assert against the same request log.
    http.post('/api/detect/:rest*', async ({ request, params }) => {
      const url = new URL(request.url)
      const headers: Record<string, string> = {}
      request.headers.forEach((v, k) => {
        headers[k] = v
      })
      detectCalls.push({
        url: request.url,
        method: request.method,
        pathname: url.pathname,
        headers,
      })
      // Synthesize an async-video shape if the URL matches the future Phase B
      // contract `^/api/detect/video/[0-9]+$`. Today no such request fires;
      // when AC-25 lands and production routes here, this responder makes the
      // jobId assertion in AC-2 stop being a "wholly absent" failure.
      if (typeof params.rest === 'string' && params.rest.startsWith('video/')) {
        return jsonResponse({ jobId: 12345 })
      }
      if (detectStatus >= 400) {
        return new Response(JSON.stringify({ error: 'simulated' }), { status: detectStatus })
      }
      return jsonResponse(detectResponse)
    }),

    // Phase B — async video detect SSE. Today no production code opens this
    // EventSource; the handler exists only so AC-2's `it.fails()` body can
    // run end-to-end without MSW unhandled-request errors when the path
    // eventually lands.
    ...(opts?.registerVideoEndpoints
      ? [
          http.get('/api/detect/stream/:jobId', ({ request }) => {
            sseOpens.push({ url: new URL(request.url).pathname })
            return sse([
              { event: 'progress', data: { pct: 50 }, id: '1' },
              { event: 'done', data: { detections: [] }, id: '2' },
            ])
          }),
        ]
      : []),

    // Bootstrap — minimal handlers so <AnnotationsPage> mounts cleanly and
    // <MediaList> shows the seeded media item.
    http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
    http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
    http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
    http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
    http.get('/api/annotations/media', ({ request }) => {
      const url = new URL(request.url)
      const page = Number(url.searchParams.get('page') ?? '1')
      const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
      return jsonResponse(paginate(items, page, pageSize))
    }),
    http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
    http.get('/api/annotations/classes', () => jsonResponse([])),
    http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 0, statusCounts: {} })),
  ]
  server.use(...handlers)
  return { detectCalls, sseOpens }
}

// The Detect button label comes from i18n key `annotations.detect`, which
// resolves to `'AI Detect'` in the en bundle (see `src/i18n/en.json`). Match
// the localized string rather than the i18n key so the test stays robust
// against future copy tweaks while still asserting on the rendered DOM.
const DETECT_BUTTON_NAME = /AI Detect/i

async function selectMediaAndClickDetect(mediaName: string): Promise<void> {
  const mediaItem = await screen.findByText(mediaName)
  await userEvent.click(mediaItem)
  // The Detect button lives in <AnnotationsSidebar>'s header. It is rendered
  // unconditionally but is `disabled` until selectedMedia is non-null —
  // userEvent.click on a disabled element is a no-op, so wait for it to
  // enable first.
  await waitFor(() => {
    const btn = screen.getByRole('button', { name: DETECT_BUTTON_NAME })
    expect(btn).not.toBeDisabled()
  })
  await userEvent.click(screen.getByRole('button', { name: DETECT_BUTTON_NAME }))
}

describe('AZ-461 — detection endpoints (sync / async / long-video header)', () => {
  beforeEach(() => {
    seedBearer()
  })

  describe('AC-1 (FT-P-11) — sync image detect URL canary', () => {
    it('clicks Detect on an image and observes exactly one POST whose URL matches /api/detect/<id>', async () => {
      // Arrange
      const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedImageMedia] })
      renderWithProviders(
        <FlightProvider>
          <AnnotationsPage />
        </FlightProvider>,
      )

      // Act
      await selectMediaAndClickDetect(seedImageMedia.name)

      // Assert — exactly one POST fired against the contract URL.
      await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
      expect(detectCalls[0].method).toBe('POST')
      // FT-P-11 contract regex: `^/api/detect/[0-9]+$`. Numeric media id makes
      // production's `/api/detect/${media.id}` satisfy this regex today.
      expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/[0-9]+$/)
      expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_MEDIA_ID}`)

      clearBearer()
    })
  })

  describe('AC-2 (FT-P-12) — async video detect endpoint + SSE (Phase B target — QUARANTINE)', () => {
    it.fails(
      'POSTs `/api/detect/video/<id>`, response carries jobId, EventSource opens on `/api/detect/stream/<jobId>`',
      async () => {
        // Per task-spec AC-2: "FT-P-12 is implemented and registered, but
        // marked Result: QUARANTINE in the CSV report until AC-25 (Phase B)
        // lands. The test code itself runs (does not just `xit`) and produces
        // a clear log entry." Today's production code POSTs
        // `/api/detect/${media.id}` regardless of mediaType (single endpoint
        // shape), so the assertion below fails. When AC-25 introduces a
        // separate `/api/detect/video/<id>` POST + SSE pair, this test flips
        // to PASS automatically.
        //
        // eslint-disable-next-line no-console
        console.log('FT-P-12 awaits AC-25 / async video detect impl')

        const { detectCalls, sseOpens } = captureDetectAndBootstrap({
          mediaItems: [seedVideoMedia],
          registerVideoEndpoints: true,
          detectResponse: { jobId: 12345 },
        })
        renderWithProviders(
          <FlightProvider>
            <AnnotationsPage />
          </FlightProvider>,
        )

        // Act
        await selectMediaAndClickDetect(seedVideoMedia.name)

        // Assert — the video-routed POST shape (Phase B) and the SSE handshake.
        await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
        expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/video\/[0-9]+$/)

        // The SSE branch — production today does not call EventSource at all
        // for detect, so the polling assertion here also fails until AC-25.
        await waitFor(() => expect(sseOpens.length).toBeGreaterThan(0), { timeout: 2000 })
        expect(sseOpens[0].url).toMatch(/^\/api\/detect\/stream\/[0-9]+$/)

        clearBearer()
      },
    )

    it('control: production posts to /api/detect/<id> regardless of mediaType (single-endpoint drift)', async () => {
      // Pin the CURRENT (drift) behavior so a regression that, e.g., stops
      // sending the request at all is caught even before AC-25 lifts the
      // QUARANTINE. When AC-25 introduces a separate video endpoint, this
      // control test will need to be adjusted (the pinned URL will change).
      const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
      renderWithProviders(
        <FlightProvider>
          <AnnotationsPage />
        </FlightProvider>,
      )

      await selectMediaAndClickDetect(seedVideoMedia.name)

      await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
      // Today: single endpoint, same shape for image and video.
      expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_VIDEO_MEDIA_ID}`)

      clearBearer()
    })
  })

  describe('AC-3 (FT-P-13) — long-video detect carries `X-Refresh-Token` header', () => {
    it.fails(
      'every long-video detect request carries an `X-Refresh-Token` header (drift — production sets only Authorization)',
      async () => {
        // Production's `api.post` chain (`src/api/client.ts` request fn) sets
        // only `Authorization: Bearer <token>` and `Content-Type` for JSON
        // bodies. `X-Refresh-Token` is NOT added today. This is the documented
        // Step-4-style drift the task spec calls out ("until F7 lands and
        // the header is added per Step 4").
        const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
        renderWithProviders(
          <FlightProvider>
            <AnnotationsPage />
          </FlightProvider>,
        )

        await selectMediaAndClickDetect(seedVideoMedia.name)
        await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })

        // Headers are normalised lower-case via the Headers iterator above.
        const xRefresh = detectCalls[0].headers['x-refresh-token']
        expect(xRefresh).toBeDefined()
        expect(xRefresh).not.toBe('')

        clearBearer()
      },
    )

    it('control: production sets only Authorization header on detect (current behavior)', async () => {
      // This control proves the static check + the spy machinery work today
      // and would catch a regression that drops Authorization entirely. When
      // AC-3 flips green via Phase B, this control becomes redundant; the
      // `it.fails()` above flips and this test still passes (since
      // Authorization is also expected to remain).
      const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
      renderWithProviders(
        <FlightProvider>
          <AnnotationsPage />
        </FlightProvider>,
      )

      await selectMediaAndClickDetect(seedVideoMedia.name)
      await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })

      const auth = detectCalls[0].headers['authorization']
      expect(auth).toBeDefined()
      expect(auth).toMatch(/^Bearer /)

      clearBearer()
    })
  })
})
