import { useEffect } from 'react'
import { describe, it, expect, beforeEach, vi, afterEach } 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 { FlightProvider, useFlight } from '../src/components'
import { AnnotationsPage } from '../src/features/annotations'
import { seedFlights } from './fixtures/seed_flights'

// AZ-476 — Upload >500 MB → 413 → user-visible error (no alert).
//
// AC-1 (FT-N-06 + NFT-RES-07): When nginx returns 413 on an oversized upload,
//                              the SPA shows an in-DOM error (toast or inline)
//                              carrying an i18n-keyed message.
//                              Production today (`MediaList.uploadFiles`)
//                              catches the upload failure silently and falls
//                              through to local mode — no error region, no
//                              i18n key. `it.fails()` + control.
// AC-2 (no alert):             The 413 path does NOT invoke `alert()`.
//                              Today the type-rejection path DOES invoke
//                              alert() (line 111 of MediaList.tsx) — but ONLY
//                              for unsupported file types, not for size. For
//                              the 413 path this is PASS today (vacuous —
//                              the failure is silently swallowed). The test
//                              asserts the spy count for both alert and the
//                              MSW POST so the contract is pinned.

const FLIGHT = seedFlights[0]

interface UploadRig {
  posts: { url: string; pathname: string; status: number }[]
}

function rigUploadEnv(opts: { uploadStatus: number }): UploadRig {
  const posts: { url: string; pathname: string; status: number }[] = []

  server.use(
    http.get('/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-az476',
        userId: 'user-az476',
        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([], 1, 1000))),
    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: {} }),
    ),

    // Batch upload endpoint — return the configured status (413 for the
    // contract test; 200 for the happy-path control).
    http.post('/api/annotations/media/batch', ({ request }) => {
      const url = new URL(request.url)
      posts.push({
        url: request.url,
        pathname: url.pathname,
        status: opts.uploadStatus,
      })
      if (opts.uploadStatus === 413) {
        return new Response('Request entity too large', { status: 413 })
      }
      return jsonResponse([])
    }),
  )

  return { posts }
}

// Tiny helper component: synchronously seeds the FlightContext's selectedFlight
// on mount. This sidesteps the async user-settings → flights/<id> rehydration
// chain so the test's `if (selectedFlight)` branch in MediaList.uploadFiles is
// guaranteed live by the time we trigger the upload. The chain is exercised
// elsewhere (AZ-463); duplicating it here only adds flake to the AZ-476 contract.
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
  const { selectFlight, selectedFlight } = useFlight()
  useEffect(() => {
    if (!selectedFlight) selectFlight(FLIGHT)
  }, [selectFlight, selectedFlight])
  return <>{children}</>
}

function buildOversizedFile(): File {
  // Spec: e2e sends a sparse 501 MB file. The fast test does not need 501 MB
  // of bytes — MSW's 413 response is the wire signal under test, not the
  // request payload size. Use a 1 KB placeholder so the harness stays cheap;
  // the size header is what the server (real nginx) gates on, and the SPA
  // observes only the 413 response. Comment kept inline so a future reader
  // does not "fix" this by allocating 501 MB and slowing CI.
  const blob = new Blob([new Uint8Array(1024)], { type: 'video/mp4' })
  return new File([blob], 'huge_recon_video.mp4', { type: 'video/mp4' })
}

async function dropFile(file: File): Promise<void> {
  // MediaList renders three file inputs:
  //   inputs[0] — dropzone (react-dropzone, hidden, fed by drag/drop UX).
  //   inputs[1] — "Open File" label (direct onChange → uploadFiles).
  //   inputs[2] — folder picker (webkitdirectory, direct onChange).
  // We exercise the "Open File" path because its onChange handler is a thin,
  // synchronous wrapper that calls `uploadFiles(files)` directly. This avoids
  // react-dropzone's internal DataTransfer machinery (which requires a real
  // drop event in JSDOM) while still hitting the same `uploadFiles` code
  // path under test for the 413 contract.
  //
  // The "Open File" input has `className="hidden"` (Tailwind `display: none`).
  // userEvent.upload attempts to click the input first; on a hidden node this
  // fails silently in some JSDOM versions. We bypass the click by setting
  // the file list directly and dispatching `change` — equivalent to React's
  // onChange wiring path.
  const inputs = document.querySelectorAll<HTMLInputElement>('input[type="file"]')
  const target = inputs[1]
  Object.defineProperty(target, 'files', {
    value: [file],
    configurable: true,
  })
  fireEvent.change(target)
  // Yield once so React processes the change → uploadFiles() → api.upload().
  await new Promise((r) => setTimeout(r, 0))
}

describe('AZ-476 — upload 501 MB → 413 → user-visible error (no alert)', () => {
  let createObjectURLSpy: ReturnType<typeof vi.fn> | null = null
  let revokeObjectURLSpy: ReturnType<typeof vi.fn> | null = null

  let originalCreateObjectURL: typeof URL.createObjectURL | undefined
  let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined

  beforeEach(() => {
    seedBearer()
    // JSDOM lacks URL.createObjectURL / revokeObjectURL; production's
    // local-mode fallback in MediaList.tsx calls them on the dropped File.
    //
    // CRITICAL: patch the methods on the URL constructor directly. Do NOT use
    // `vi.stubGlobal('URL', { ...URL, createObjectURL })` — that replaces the
    // global URL with a plain object, which silently breaks every `new URL(...)`
    // call downstream (the SPA's API helper, MSW's request matching, etc.) and
    // the resulting fetches never reach the test's MSW handler.
    originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
      .createObjectURL
    originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
      .revokeObjectURL
    createObjectURLSpy = vi.fn(
      (file: Blob | File) => `blob:az476-${(file as File).name ?? 'unknown'}`,
    )
    revokeObjectURLSpy = vi.fn()
    ;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
      createObjectURLSpy as unknown as typeof URL.createObjectURL
    ;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
      revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
  })

  afterEach(() => {
    clearBearer()
    if (originalCreateObjectURL === undefined) {
      delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
    } else {
      ;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
        originalCreateObjectURL
    }
    if (originalRevokeObjectURL === undefined) {
      delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
    } else {
      ;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
        originalRevokeObjectURL
    }
    vi.unstubAllGlobals()
    vi.restoreAllMocks()
    createObjectURLSpy = null
    revokeObjectURLSpy = null
  })

  describe('AC-1 (FT-N-06 + NFT-RES-07) — user-visible 413 error', () => {
    it.fails(
      'a 413 from /api/annotations/media/batch surfaces an in-DOM error region with an i18n-keyed message',
      async () => {
        // Drift: production catches the upload failure silently in
        // `MediaList.uploadFiles`'s try/catch and falls through to local mode
        // (creating blob URLs). No error region is rendered, no i18n key
        // exists for the 413 case. The assertion below requires an element
        // with role="alert" carrying an upload-size message; both are absent
        // today, so the test fails until production wires the toast and the
        // i18n string.
        const { posts } = rigUploadEnv({ uploadStatus: 413 })
        renderWithProviders(
          <FlightProvider>
            <FlightSeed>
              <AnnotationsPage />
            </FlightSeed>
          </FlightProvider>,
        )
        await waitFor(() => {
          expect(document.querySelector('input[type="file"]')).not.toBeNull()
        })

        // Act
        const file = buildOversizedFile()
        await dropFile(file)

        // The POST fires, MSW returns 413.
        await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
        expect(posts[0].pathname).toBe('/api/annotations/media/batch')
        expect(posts[0].status).toBe(413)

        // Assert — an error region appears in the DOM. The contract is:
        // role="alert" + a message carrying an upload-size phrase. The exact
        // i18n key (e.g., "annotations.uploadTooLarge") is set by the
        // remediation task; the test matches on the rendered text shape.
        const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
        expect(alertEl.textContent ?? '').toMatch(/too large|exceeds|413/i)
      },
    )

    it('control: production silently falls through to local mode on 413 (current drift)', async () => {
      // Pin the current behavior so a regression that, e.g., starts throwing
      // an unhandled exception (which would leak to the React error boundary
      // and crash the page) is visible immediately. Today: POST fires, 413
      // returns, code falls through, blob URL is created locally, the file
      // appears in the media list under its original name.
      const { posts } = rigUploadEnv({ uploadStatus: 413 })
      renderWithProviders(
        <FlightProvider>
          <FlightSeed>
            <AnnotationsPage />
          </FlightSeed>
        </FlightProvider>,
      )
      await waitFor(() => {
        expect(document.querySelector('input[type="file"]')).not.toBeNull()
      })

      const file = buildOversizedFile()
      await dropFile(file)
      await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })

      // The local-mode side effect: the file appears in the rendered media
      // list with its original name. This pins the silent-fall-through drift.
      await waitFor(() => {
        expect(screen.getByText(/huge_recon_video\.mp4/i)).toBeInTheDocument()
      })
    })
  })

  describe('AC-2 — no `alert()` on the 413 path', () => {
    it('the 413 path does NOT invoke window.alert() today (vacuous PASS — failure is silently swallowed)', async () => {
      // The current drift makes this test pass for the wrong reason: there's
      // no alert because there's no error handling at all. When AC-1 lands
      // and an in-DOM error region is wired, this contract still must hold —
      // i.e., even with proper error surfacing, alert() stays out of the
      // path. The static check (STC-SEC7) provides the source-level gate;
      // this runtime test is the defence-in-depth assertion required by the
      // task spec.
      const alertSpy = vi.fn()
      vi.stubGlobal('alert', alertSpy)

      const { posts } = rigUploadEnv({ uploadStatus: 413 })
      renderWithProviders(
        <FlightProvider>
          <FlightSeed>
            <AnnotationsPage />
          </FlightSeed>
        </FlightProvider>,
      )
      await waitFor(() => {
        expect(document.querySelector('input[type="file"]')).not.toBeNull()
      })

      const file = buildOversizedFile()
      await dropFile(file)
      await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
      // Give the error path time to either render an alert (drift) or not.
      await new Promise((r) => setTimeout(r, 100))

      expect(alertSpy).not.toHaveBeenCalled()
    })
  })
})
