import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { render, act, cleanup } from '@testing-library/react'
import { createSSE } from '../src/api/sse'
import { setToken } from '../src/api/client'
import { createFakeEventSource, type FakeEventSource } from './helpers/sse-mock'

// AZ-458 — SSE lifecycle + bearer-rotation reconnect.
//
//   FT-P-09 — annotation-status SSE opens on <AnnotationsPage> mount       (QUARANTINE)
//   FT-P-10 — annotation-status SSE closes on unmount                       (QUARANTINE)
//   FT-P-18 — live-GPS SSE opens within 5 s of flight select                (fast)
//   FT-P-19 — live-GPS SSE closes within 1 s of deselect                    (fast)
//   NFT-PERF-03 — SSE bearer-rotation reconnect ≤ 5 s                       (e2e — see e2e/tests/sse_lifecycle.e2e.ts)
//   NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select            (fast — same as FT-P-18)
//   NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect                (fast — same as FT-P-19)
//   NFT-PERF-06 — annotation-status SSE unsubscribes within 1 s on unmount  (QUARANTINE)
//   NFT-RES-02 — SSE bearer rotation — both streams reconnect within 5 s    (e2e — see e2e companion)
//
// Black-box discipline: per AZ-458 AC-3 we do NOT stub `src/api/sse.ts`. We
// patch `globalThis.EventSource` so we observe what URLs the production
// module passes to the platform `new EventSource(url)` and when it calls
// `.close()`. The consumer pattern (`useEffect` + `createSSE` + cleanup) is
// reproduced by a small `<SseConsumer>` test harness that mirrors the shape
// in `src/features/flights/FlightsPage.tsx:65-68`.
//
// Production status notes (drift documentation):
//   - AnnotationsPage today opens NO SSE — there is no annotation-status
//     subscription in `src/features/annotations/AnnotationsPage.tsx`. The
//     annotation-status scenarios are QUARANTINEd until the production path
//     lands; the assertions below describe what the test will look like.
//   - createSSE reads the bearer via `getToken()` at construction time but
//     the FlightsPage `useEffect` deps are `[selectedFlight, mode]` only —
//     the effect does NOT re-run when the bearer rotates. Bearer rotation
//     therefore does NOT reconnect today; this is the AC-2 drift, captured
//     via `it.fails()` against a `<SseConsumer>` that uses the same deps
//     shape as the production consumer.

type EventSourceCtor = new (url: string) => EventSource

let constructed: Array<FakeEventSource & { closed: boolean }> = []
let originalEventSource: EventSourceCtor | undefined

function installFakeEventSource() {
  constructed = []
  originalEventSource = (globalThis as { EventSource?: EventSourceCtor }).EventSource
  class StubEventSource extends EventTarget {
    public url: string
    public readyState: 0 | 1 | 2 = 0
    public closed = false
    constructor(url: string) {
      super()
      this.url = url
      const fake = createFakeEventSource(url) as FakeEventSource & { closed: boolean }
      fake.closed = false
      const origClose = fake.close.bind(fake)
      fake.close = () => {
        fake.closed = true
        origClose()
      }
      constructed.push(fake)
      // Patch this instance to forward dispatch/close to the fake so
      // production code's `source.close()` flows through.
      this.close = () => fake.close()
      const inst = this as unknown as { onmessage?: (e: MessageEvent) => void; onerror?: (e: Event) => void; readyState: number }
      inst.readyState = 1
      fake.addEventListener('message', (e) => inst.onmessage?.(e as MessageEvent))
      fake.addEventListener('error', (e) => inst.onerror?.(e))
    }
    close() { /* replaced in constructor */ }
  }
  ;(globalThis as { EventSource?: EventSourceCtor }).EventSource = StubEventSource as unknown as EventSourceCtor
}

function restoreEventSource() {
  if (originalEventSource === undefined) {
    delete (globalThis as { EventSource?: EventSourceCtor }).EventSource
  } else {
    ;(globalThis as { EventSource?: EventSourceCtor }).EventSource = originalEventSource
  }
}

beforeEach(() => {
  installFakeEventSource()
})

afterEach(() => {
  cleanup()
  restoreEventSource()
  setToken(null)
})

// Consumer pattern mirror — same deps shape as FlightsPage.tsx:65-68.
function SseConsumer({ active, flightId, mode }: { active: boolean; flightId: string | null; mode: 'gps' | 'params' }) {
  const [received, setReceived] = useState<unknown[]>([])
  useEffect(() => {
    if (!active || !flightId || mode !== 'gps') return
    return createSSE<{ lat: number; lon: number }>(
      `/api/flights/${flightId}/live-gps`,
      (data) => setReceived((prev) => [...prev, data]),
    )
  }, [active, flightId, mode])
  return <div data-testid="sse-events">{received.length}</div>
}

// Bearer-rotation consumer mirror — same deps shape (no token dep). This
// reproduces the production drift: rotating the bearer does NOT cause a
// reconnect because the effect dep array doesn't include the token.
function SseConsumerNoTokenDep({ flightId }: { flightId: string | null }) {
  useEffect(() => {
    if (!flightId) return
    return createSSE(`/api/flights/${flightId}/live-gps`, () => { /* drop */ })
  }, [flightId])
  return null
}

describe('AZ-458 / createSSE — open/close lifecycle (FT-P-18/19, NFT-PERF-04/05)', () => {
  describe('FT-P-18 / NFT-PERF-04 — open on flight select', () => {
    it('opens exactly one EventSource when a flight is selected in gps mode', () => {
      // Arrange
      setToken('rot-token-A')

      // Act — mount with selectedFlight=flight-1 + mode=gps
      render(<SseConsumer active flightId="flight-1" mode="gps" />)

      // Assert AC-1: exactly one EventSource constructed; URL targets the
      // selected flight's live-gps endpoint and carries the bearer.
      expect(constructed).toHaveLength(1)
      expect(constructed[0].url).toContain('/api/flights/flight-1/live-gps')
      expect(constructed[0].url).toContain('access_token=rot-token-A')
    })

    it('does NOT open an EventSource when mode != gps (negative control)', () => {
      setToken('rot-token-A')
      render(<SseConsumer active flightId="flight-1" mode="params" />)
      expect(constructed).toHaveLength(0)
    })
  })

  describe('FT-P-19 / NFT-PERF-05 — close on deselect', () => {
    it('closes the EventSource when the flight is deselected', () => {
      setToken('rot-token-A')
      const { rerender } = render(<SseConsumer active flightId="flight-1" mode="gps" />)
      expect(constructed).toHaveLength(1)
      const opened = constructed[0]
      expect(opened.closed).toBe(false)

      // Act — deselect flight (flightId → null). The useEffect cleanup runs
      // synchronously on the effect re-run, which is well under the 1 s budget.
      rerender(<SseConsumer active flightId={null} mode="gps" />)

      // Assert AC-1: EventSource closed.
      expect(opened.closed).toBe(true)
      // No new construction (the effect early-returns when flightId is null).
      expect(constructed).toHaveLength(1)
    })

    it('closes on unmount (cleanup runs as part of teardown)', () => {
      setToken('rot-token-A')
      const { unmount } = render(<SseConsumer active flightId="flight-1" mode="gps" />)
      expect(constructed).toHaveLength(1)
      const opened = constructed[0]

      unmount()
      expect(opened.closed).toBe(true)
    })
  })
})

describe('AZ-458 / createSSE — bearer rotation (AC-2, NFT-PERF-03, NFT-RES-02)', () => {
  it('captures the bearer that was current at construction time (sanity check)', () => {
    setToken('boot-token')
    render(<SseConsumer active flightId="flight-1" mode="gps" />)
    expect(constructed[0].url).toContain('access_token=boot-token')
  })

  it.fails(
    'AC-2 drift — when the bearer rotates AFTER the SSE is open, a new EventSource is created with the new token within 5 s (today the effect deps do not include the token, so this does NOT happen)',
    async () => {
      // Arrange — open the SSE with the bootstrap token.
      setToken('boot-token')
      render(<SseConsumerNoTokenDep flightId="flight-1" />)
      expect(constructed).toHaveLength(1)
      expect(constructed[0].url).toContain('access_token=boot-token')

      // Act — rotate the bearer (as <AuthContext> would after a successful
      // refresh).
      await act(async () => {
        setToken('rotated-token-B')
        // Yield to the React scheduler so any token-dependent effect could fire.
        await Promise.resolve()
      })

      // Assert AC-2: a second EventSource is opened with the new token.
      // Today this assertion fails because the consumer's useEffect doesn't
      // depend on the token — the old EventSource stays connected with the
      // stale `access_token=boot-token`.
      expect(constructed).toHaveLength(2)
      expect(constructed[1].url).toContain('access_token=rotated-token-B')
    },
  )

  it('control — bearer rotation today does NOT reconnect the live-GPS SSE (drift seen)', async () => {
    setToken('boot-token')
    render(<SseConsumerNoTokenDep flightId="flight-1" />)
    expect(constructed).toHaveLength(1)
    const stale = constructed[0]
    await act(async () => {
      setToken('rotated-token-B')
      await Promise.resolve()
    })
    // QUARANTINE evidence: still only one EventSource; it still carries the
    // stale token. The e2e companion exercises the real wire and will FAIL
    // (correctly) once the spec is enforced suite-side.
    expect(constructed).toHaveLength(1)
    expect(stale.url).toContain('access_token=boot-token')
    expect(stale.closed).toBe(false)
  })
})

describe('AZ-458 / AnnotationsPage SSE (FT-P-09, FT-P-10, NFT-PERF-06)', () => {
  it.skip(
    'QUARANTINE (no production behavior): annotation-status SSE opens on <AnnotationsPage> mount and closes on unmount within 1 s',
    () => {
      // When AnnotationsPage gains an annotation-status subscription, the
      // assertion shape (using the same EventSource stub as the live-GPS
      // tests above) is:
      //   1. mount <AnnotationsPage>
      //   2. expect(constructed).toHaveLength(1) — one annotation-status SSE
      //   3. expect(constructed[0].url).toContain('/api/annotations/.../status')
      //   4. unmount; expect(constructed[0].closed).toBe(true)
      // The test is skipped today because src/features/annotations/AnnotationsPage.tsx
      // does not open any SSE; asserting against absent behavior would be noise.
      expect(true).toBe(false) /* placeholder */
    },
  )

  it('control — AnnotationsPage opens NO SSE today (drift evidence; the source does not call createSSE)', () => {
    // We don't mount AnnotationsPage here (it pulls Leaflet-free but heavy
    // canvas / video setup that has no bearing on the SSE assertion). The
    // observable proof is structural: the only `createSSE` consumer today is
    // FlightsPage. This test exists so the QUARANTINE state is visible in
    // the test report rather than only in comments.
    expect(constructed).toHaveLength(0)
  })
})
