import { useEffect, useState } from 'react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } 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 { createSSE } from '../src/api'
import App from '../src/App'
import { AnnotationsPage } from '../src/features/annotations'
import { FlightProvider, useFlight } from '../src/components'
import { seedFlights } from './fixtures/seed_flights'
import {
  AnnotationSource,
  AnnotationStatus,
  Affiliation,
  CombatReadiness,
  MediaType,
  MediaStatus,
} from '../src/types'
import type { Media, AnnotationListItem } from '../src/types'

// AZ-478 — network-resilience contracts.
//
// AC-1 (NFT-RES-03): all `/api/*` requests fail with network errors at boot.
//                    The SPA must:
//                      a) render an error state (not silently degrade), and
//                      b) NOT register a service worker / offline cache.
//                    Today (drift): the AuthProvider's refresh fails →
//                    `user` stays null → ProtectedRoute redirects to /login;
//                    the LoginPage form renders, NOT a network-error
//                    indicator. (b) is enforced statically (STC-N3) AND
//                    asserted at runtime here for defence in depth.
// AC-2 (NFT-RES-09): annotation download via `canvas.toBlob` on a tainted
//                    canvas throws SecurityError. The page must NOT crash;
//                    a user-visible fallback (alternative download path or
//                    an in-DOM error) must be rendered.
//                    Today (drift): `AnnotationsPage.handleDownload` calls
//                    `canvas.toBlob` without a try/catch — the SecurityError
//                    escapes as an unhandled rejection from the async
//                    handleDownload. No fallback UI is rendered.
// AC-3 (NFT-RES-10): when an SSE EventSource fires `error` with
//                    `readyState === 2` (CLOSED), within 2 s a
//                    connection-lost indicator must appear in the DOM with
//                    an i18n-keyed text.
//                    Today (drift): `src/api/sse.ts` calls `onError?.(e)`
//                    but no consumer renders any user-visible indicator,
//                    and there is no `connection-lost` i18n key.

// ---------------------------------------------------------------------------
// AC-1 — offline at boot.
// ---------------------------------------------------------------------------

describe('AZ-478 — AC-1 (NFT-RES-03): network offline at boot', () => {
  let originalServiceWorker: PropertyDescriptor | undefined
  let unhandledHandler: ((reason: unknown) => void) | null = null
  const swallowed: unknown[] = []

  beforeEach(() => {
    // Every /api/* request errors at the network layer (DNS/conn refused).
    // This drives AuthProvider's refresh down its `.catch` branch.
    server.use(
      http.all('/api/*', () => HttpResponse.error()),
    )

    // Provide a minimal `navigator.serviceWorker` so we can assert
    // registrations stays empty. JSDOM has no SW by default.
    originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker')
    Object.defineProperty(navigator, 'serviceWorker', {
      configurable: true,
      get() {
        return {
          getRegistrations: async () => [],
          register: vi.fn(),
        }
      },
    })

    // Catch the deliberate refresh failure so vitest doesn't error on the
    // unhandled rejection.
    swallowed.length = 0
    unhandledHandler = (reason: unknown) => {
      swallowed.push(reason)
    }
    process.on('unhandledRejection', unhandledHandler)
  })

  afterEach(() => {
    if (unhandledHandler) {
      process.off('unhandledRejection', unhandledHandler)
      unhandledHandler = null
    }
    if (originalServiceWorker) {
      Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker)
    } else {
      // navigator.serviceWorker is non-configurable in some envs; deletion
      // may silently no-op — that's fine for cleanup.
      try {
        delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
      } catch {
        // ignore
      }
    }
  })

  it('SPA does NOT register a service worker (defence in depth, also enforced statically as STC-N3)', async () => {
    // Arrange / Act — boot the app at "/".
    renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })

    // Allow the AuthProvider's refresh promise to reject.
    await new Promise((r) => setTimeout(r, 50))

    // Assert — no SW was registered. STC-N3 already gates this at the source
    // tree, but the runtime check catches a future regression where the
    // registration is moved to a dynamically-imported module that grep
    // misses.
    const regs = await navigator.serviceWorker.getRegistrations()
    expect(regs).toEqual([])
  })

  it.fails(
    'SPA renders a user-visible network-error indicator when boot APIs are offline',
    async () => {
      // Drift: today the fall-through behaviour is "redirect to /login".
      // The LoginPage form renders; no error banner / offline indicator
      // exists. Spec requires an in-DOM indicator (e.g., role="alert" with
      // an i18n-keyed message such as "common.networkError").
      renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })

      const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
      expect(banner.textContent ?? '').toMatch(/offline|network|connection/i)
    },
  )

  it('control: today the SPA falls through to /login (drift snapshot)', async () => {
    // Pins current behaviour. When AC-1 lands and the SPA shows a network
    // banner instead, this test becomes flaky — the redirect may not happen
    // — and the snapshot has to be updated alongside the AC fix.
    renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })

    // The login form's i18n header text is "AZAION".
    await waitFor(() => expect(screen.getByText('AZAION')).toBeInTheDocument(), {
      timeout: 2000,
    })
    // The login form is rendered (Sign In submit button is the i18n key
    // login.submit). LoginPage doesn't wire htmlFor between <label> and
    // <input>, so getByLabelText doesn't resolve — match via the submit
    // button text instead.
    expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
  })
})

// ---------------------------------------------------------------------------
// AC-2 — tainted-canvas fallback.
// ---------------------------------------------------------------------------

const FLIGHT = seedFlights[0]

const imageMedia: Media = {
  id: 'media-az478',
  name: 'tainted-fixture.jpg',
  path: '/media/tainted-fixture.jpg',
  mediaType: MediaType.Image,
  mediaStatus: MediaStatus.New,
  duration: null,
  annotationCount: 1,
  waypointId: null,
  userId: 'user-az478',
}

const seedAnnotation: AnnotationListItem = {
  id: 'ann-az478',
  mediaId: imageMedia.id,
  time: null,
  createdDate: '2026-05-11T00:00:00Z',
  userId: 'user-az478',
  source: AnnotationSource.Manual,
  status: AnnotationStatus.Created,
  isSplit: false,
  splitTile: null,
  detections: [
    {
      id: 'det-az478',
      classNum: 0,
      label: 'class-0',
      confidence: 0.9,
      affiliation: Affiliation.Hostile,
      combatReadiness: CombatReadiness.NotReady,
      centerX: 0.5,
      centerY: 0.5,
      width: 0.1,
      height: 0.1,
    },
  ],
}

function rigDownloadEnv() {
  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-az478',
        userId: 'user-az478',
        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([imageMedia], 1, 1000))),
    http.get('/api/annotations/annotations', () =>
      jsonResponse(paginate([seedAnnotation], 1, 1000)),
    ),
    http.get('/api/annotations/classes', () => jsonResponse([])),
    http.get('/api/annotations/dataset/info', () =>
      jsonResponse({ totalCount: 0, statusCounts: {} }),
    ),
  )
}

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

describe('AZ-478 — AC-2 (NFT-RES-09): tainted-canvas annotation download fallback', () => {
  let originalToBlob: typeof HTMLCanvasElement.prototype.toBlob
  let originalImage: typeof globalThis.Image
  let toBlobCalls: number
  let unhandledHandler: ((reason: unknown) => void) | null = null
  const swallowed: unknown[] = []
  let originalCreateObjectURL: typeof URL.createObjectURL | undefined
  let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined

  beforeEach(() => {
    seedBearer()
    rigDownloadEnv()
    toBlobCalls = 0

    // JSDOM lacks URL.createObjectURL / revokeObjectURL; AnnotationsPage's
    // download path uses both for the .txt blob that's emitted BEFORE the
    // canvas-to-PNG path. Patch the methods on the URL constructor directly
    // (see _docs/LESSONS.md "Don't replace URL via vi.stubGlobal('URL', ...)"
    // for why).
    originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
      .createObjectURL
    originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
      .revokeObjectURL
    ;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
      ((blob: Blob) => `blob:az478-${(blob as { type?: string }).type ?? 'unknown'}`) as unknown as typeof URL.createObjectURL
    ;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
      (() => {/* noop */ }) as unknown as typeof URL.revokeObjectURL

    // Stub `Image` so handleDownload's `await new Promise(res => { img.onload = res })`
    // resolves synchronously with a 640×480 frame. Production code requires
    // `naturalWidth` / `naturalHeight` to be populated for `drawImage` to fire.
    originalImage = globalThis.Image
    globalThis.Image = class FakeImage extends EventTarget {
      onload: ((e: Event) => unknown) | null = null
      onerror: ((e: Event) => unknown) | null = null
      crossOrigin: string | null = null
      naturalWidth = 640
      naturalHeight = 480
      private _src = ''
      get src(): string { return this._src }
      set src(v: string) {
        this._src = v
        // Fire onload on next microtask so the await Promise sees a resolution.
        queueMicrotask(() => this.onload?.(new Event('load')))
      }
    } as unknown as typeof globalThis.Image

    // Make canvas.getContext return a working stub so handleDownload reaches
    // the `canvas.toBlob` line. JSDOM's default returns null, which would
    // short-circuit the function before the SecurityError path is exercised.
    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

    // Force `toBlob` to throw SecurityError — this simulates the canvas
    // having been tainted by a cross-origin draw without CORS headers
    // (browsers throw `SecurityError` synchronously on `toBlob` /
    // `toDataURL` against a tainted canvas).
    originalToBlob = HTMLCanvasElement.prototype.toBlob
    HTMLCanvasElement.prototype.toBlob = function tainted(): void {
      toBlobCalls += 1
      throw new DOMException(
        'The canvas has been tainted by cross-origin data',
        'SecurityError',
      )
    }

    // Capture the resulting unhandled rejection (production lacks try/catch
    // around toBlob — see AnnotationsPage.tsx:139). Without this, vitest
    // exits non-zero even when test assertions pass.
    swallowed.length = 0
    unhandledHandler = (reason: unknown) => {
      const msg = (reason instanceof Error ? reason.message : String(reason)) ?? ''
      if (/tainted|SecurityError/i.test(msg)) {
        swallowed.push(reason)
        return
      }
      // Re-throw anything we didn't expect.
      throw reason
    }
    process.on('unhandledRejection', unhandledHandler)
  })

  afterEach(() => {
    clearBearer()
    if (unhandledHandler) {
      process.off('unhandledRejection', unhandledHandler)
      unhandledHandler = null
    }
    HTMLCanvasElement.prototype.toBlob = originalToBlob
    globalThis.Image = originalImage
    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
    }
  })

  it.fails(
    'tainted-canvas download surfaces an in-DOM fallback (alt download path or role="alert")',
    async () => {
      // Drift: today, `canvas.toBlob` throws and the error escapes the async
      // handleDownload. No alert / fallback link is rendered. Test passes
      // once production wires a try/catch around toBlob and renders either:
      //   - an `<a download="...txt">` fallback, OR
      //   - a `role="alert"` carrying an i18n-keyed message (e.g.,
      //     "annotations.downloadTaintedCanvas").
      renderWithProviders(
        <FlightProvider>
          <FlightSeed>
            <AnnotationsPage />
          </FlightSeed>
        </FlightProvider>,
      )

      const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
      await userEvent.click(mediaItem)

      // Wait for the annotation to render in the right sidebar, then click it
      // (only a selected annotation enables the download button).
      const annRow = await waitFor(() => {
        const rows = screen.getAllByText('—')
        if (rows.length === 0) throw new Error('annotation row not yet visible')
        return rows[0]
      })
      await userEvent.click(annRow)

      const downloadBtn = await screen.findByTitle(/download/i)
      await waitFor(() => expect(downloadBtn).not.toBeDisabled())
      await userEvent.click(downloadBtn)

      // Assert — `toBlob` was hit (we reached the tainted-canvas branch).
      await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })

      // Assert — fallback UI rendered.
      const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
      expect(banner.textContent ?? '').toMatch(/download|tainted|cross.?origin/i)
    },
  )

  it('control: page does NOT crash even though toBlob throws (drift snapshot)', async () => {
    // Pins the current behaviour: the SecurityError propagates as an
    // unhandled rejection, captured by our process listener; the React tree
    // stays mounted (the alternative — a thrown SecurityError taking down
    // the page — would be a critical regression and would surface as an
    // uncaught error in the test runner).
    renderWithProviders(
      <FlightProvider>
        <FlightSeed>
          <AnnotationsPage />
        </FlightSeed>
      </FlightProvider>,
    )

    const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
    await userEvent.click(mediaItem)
    const annRow = await waitFor(() => {
      const rows = screen.getAllByText('—')
      if (rows.length === 0) throw new Error('annotation row not yet visible')
      return rows[0]
    })
    await userEvent.click(annRow)
    const downloadBtn = await screen.findByTitle(/download/i)
    await waitFor(() => expect(downloadBtn).not.toBeDisabled())
    await userEvent.click(downloadBtn)

    await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
    // Tree still mounted — the media list header (i18n key annotations.title
    // → "Annotations") is still present.
    await waitFor(() => {
      // Any element still under document.body proves the page didn't crash.
      expect(document.body.contains(mediaItem)).toBe(true)
    })
    // The unhandled-rejection listener captured exactly one SecurityError.
    expect(swallowed.length).toBeGreaterThan(0)
    expect(String(swallowed[0])).toMatch(/SecurityError|tainted/i)
  })
})

// ---------------------------------------------------------------------------
// AC-3 — SSE disconnect indicator.
// ---------------------------------------------------------------------------

interface FakeES extends EventTarget {
  url: string
  readyState: 0 | 1 | 2
  close(): void
  fireError(): void
}

let constructedEs: FakeES[] = []

function installFakeEventSource(): () => void {
  const origCtor = (globalThis as { EventSource?: typeof EventSource }).EventSource
  constructedEs = []

  class FakeEventSource extends EventTarget {
    public url: string
    public readyState: 0 | 1 | 2 = 0
    public onmessage: ((e: MessageEvent) => void) | null = null
    public onerror: ((e: Event) => void) | null = null
    public onopen: ((e: Event) => void) | null = null

    constructor(url: string) {
      super()
      this.url = url
      this.readyState = 1
      const fakeRef = this as unknown as FakeES
      fakeRef.fireError = () => {
        this.readyState = 2 // CLOSED
        const ev = new Event('error')
        // Production SSE wiring uses `source.onerror` directly (not addEventListener).
        this.onerror?.(ev)
        this.dispatchEvent(ev)
      }
      constructedEs.push(fakeRef)
    }

    close(): void {
      this.readyState = 2
    }
    static readonly CONNECTING = 0
    static readonly OPEN = 1
    static readonly CLOSED = 2
  }
  ;(globalThis as { EventSource?: unknown }).EventSource = FakeEventSource as unknown as typeof EventSource

  return () => {
    if (origCtor) {
      ;(globalThis as { EventSource?: typeof EventSource }).EventSource = origCtor
    } else {
      delete (globalThis as { EventSource?: typeof EventSource }).EventSource
    }
  }
}

// Minimal consumer mirroring `AnnotationsSidebar`'s production SSE pattern
// (createSSE → onMessage → no error UI). This is the most direct boundary
// for asserting "no connection-lost indicator is rendered today".
function SseProbe(): React.ReactElement {
  const [errored, setErrored] = useState(false)
  useEffect(() => {
    return createSSE(
      '/api/annotations/annotations/events',
      () => { /* drop */ },
      () => setErrored(true),
    )
  }, [])
  // The probe deliberately renders only the error TEST hook — production
  // does NOT render any user-visible "connection-lost" banner today. The
  // AC-3 it.fails() asserts on a banner; this probe's `errored` flag
  // proves the SSE error path fired (control: spy hit), so the it.fails()
  // is failing on UI, not on event plumbing.
  return <div data-testid="sse-probe-errored">{errored ? 'errored' : 'open'}</div>
}

describe('AZ-478 — AC-3 (NFT-RES-10): SSE disconnect surfaces a connection-lost indicator', () => {
  let restoreEs: (() => void) | null = null

  beforeEach(() => {
    restoreEs = installFakeEventSource()
    server.use(
      http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
    )
  })

  afterEach(() => {
    restoreEs?.()
    restoreEs = null
  })

  it.fails(
    'within 2s of SSE error+CLOSED, a user-visible connection-lost indicator with i18n-keyed text is rendered',
    async () => {
      // Drift: production has no consumer that maps `onError` → user UI.
      // `src/api/sse.ts` calls `onError?.(e)` and individual consumers (today
      // only AnnotationsSidebar / FlightsPage) ignore that callback. There is
      // no `connection-lost` i18n key (parity sweep returned 0 hits).
      // Test passes when production wires a `<ConnectionStatus>` (or any
      // component) that surfaces the disconnected state.
      renderWithProviders(<SseProbe />)

      // Wait for the SSE to be constructed (production opens it on mount).
      await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))

      // Trigger the disconnect — error fires with readyState=CLOSED.
      const es = constructedEs[0]
      es.fireError()

      // Spec: indicator must appear within 2 s with an i18n-keyed text.
      const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
      expect(banner.textContent ?? '').toMatch(/connection|disconnect|offline/i)
    },
  )

  it('control: SSE error path fires (probe records errored=true) but no banner is rendered today', async () => {
    // Pins the current behaviour: the SSE consumer correctly observes the
    // error/CLOSED transition (the probe's local state flips), but no DOM
    // node carries an i18n-keyed connection-lost message. Removing this
    // test alongside the AC-3 fix is the migration path.
    renderWithProviders(<SseProbe />)
    await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
    const es = constructedEs[0]

    // Sanity — before the error, the probe renders "open".
    expect(screen.getByTestId('sse-probe-errored').textContent).toBe('open')

    // Fire the disconnect, then yield React's rerender.
    fireEvent.error(window) // no-op event so the next microtask fires
    es.fireError()

    await waitFor(() =>
      expect(screen.getByTestId('sse-probe-errored').textContent).toBe('errored'),
    )

    // Drift: no role="alert" exists.
    expect(screen.queryByRole('alert')).toBeNull()
  })
})
