import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { Routes, Route } from 'react-router-dom'
import { server } from '../../tests/msw/server'
import { jsonResponse } from '../../tests/msw/helpers'
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
import ProtectedRoute from './ProtectedRoute'
import { clearBearer } from '../../tests/helpers/auth'
import { opAlice, opBob, adminCarol, integratorDave, seedPermissions } from '../../tests/fixtures/seed_users'

// AZ-457 — <ProtectedRoute> behavior at the React boundary.
//   FT-N-04 / row 09 — unauthenticated /admin → redirect to /login
//   NFT-RES-08       — refresh cookie expired → redirect to /login
//                      (apiClient half lives in src/api/client.test.ts; this
//                       file asserts the React-router-level redirect path)
//
// AZ-467 — Spinner a11y, 10s timeout fallback, and RBAC route gating.
//   FT-P-32 / NFT-SEC-05    — spinner role=status + aria-live=polite + label
//   FT-P-33 / NFT-RES-04    — 10s timeout fallback (Vitest fake-timers)
//   FT-N-03 / NFT-SEC-05    — Operator → /admin redirects to /flights
//   FT-N-05 / NFT-SEC-06    — integrator-dave → /settings redirects (no SETTINGS perm)
//
// Production status (today): <ProtectedRoute> renders a plain spinner div
// without any aria-* attributes, has no timeout fallback, and does NOT check
// route-level permissions (it only gates on `user != null`). Those four ACs
// therefore fail today; the spinner a11y test uses `it.fails()` to track the
// drift, and the timeout / RBAC tests are `it.skip` (QUARANTINE) because the
// behavior is entirely absent.
//
// Black-box discipline: we import only the public ProtectedRoute component
// and react-router primitives; no internal state of <AuthContext> is read.
// Assertions are observable on the rendered DOM — sentinel components let us
// confirm which route the router settled on.

function LoginSentinel() {
  return <div data-testid="login-route">login-route</div>
}

function AdminSentinel() {
  return <div data-testid="admin-route">admin-route</div>
}

function FlightsSentinel() {
  return <div data-testid="flights-route">flights-route</div>
}

function SettingsSentinel() {
  return <div data-testid="settings-route">settings-route</div>
}

function withUser(user: typeof opAlice) {
  // AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
  // /users/me -> user. The previous shape (GET refresh returning { user, token })
  // was the broken bootstrap path the consolidation removed.
  server.use(
    http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
    http.get('/api/admin/users/me', () =>
      jsonResponse({ ...user, permissions: seedPermissions[user.id] ?? [] }),
    ),
  )
}

describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
  afterEach(() => {
    clearBearer()
  })

  describe('FT-N-04 (row 09) — unauthenticated user → /login', () => {
    it('navigating to /admin without a session redirects to /login', async () => {
      // Arrange — bootstrap refresh returns 401 (no session), AuthProvider's
      // catch arm leaves user=null and loading=false.
      server.use(
        http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
      )

      // Act
      renderWithProviders(
        <Routes>
          <Route
            path="/admin"
            element={
              <ProtectedRoute>
                <AdminSentinel />
              </ProtectedRoute>
            }
          />
          <Route path="/login" element={<LoginSentinel />} />
        </Routes>,
        { initialEntries: ['/admin'] },
      )

      // Assert — the /login sentinel renders, the /admin sentinel does not.
      await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
      expect(screen.queryByTestId('admin-route')).toBeNull()
    })

    it('shows the loading spinner before bootstrap settles, then redirects', async () => {
      // Arrange — keep the bootstrap in flight long enough to capture the
      // spinner; resolve afterward to settle the redirect.
      let resolver!: () => void
      const gate = new Promise<void>((r) => {
        resolver = r
      })
      server.use(
        http.post('/api/admin/auth/refresh', async () => {
          await gate
          return new HttpResponse(null, { status: 401 })
        }),
      )

      // Act
      const { container } = renderWithProviders(
        <Routes>
          <Route
            path="/admin"
            element={
              <ProtectedRoute>
                <AdminSentinel />
              </ProtectedRoute>
            }
          />
          <Route path="/login" element={<LoginSentinel />} />
        </Routes>,
        { initialEntries: ['/admin'] },
      )

      // Assert spinner is visible while loading.
      const spinner = container.querySelector('.animate-spin')
      expect(spinner).not.toBeNull()
      expect(screen.queryByTestId('admin-route')).toBeNull()
      expect(screen.queryByTestId('login-route')).toBeNull()

      // Resolve the gate; the route should settle on /login.
      resolver()
      await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
    })
  })

  describe('NFT-RES-08 — refresh cookie expired → redirect (React-router half)', () => {
    it('failed bootstrap refresh routes the user to /login', async () => {
      // Arrange — expired-cookie 401 + no user in context.
      server.use(
        http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
      )

      // Act
      renderWithProviders(
        <Routes>
          <Route
            path="/flights"
            element={
              <ProtectedRoute>
                <FlightsSentinel />
              </ProtectedRoute>
            }
          />
          <Route path="/login" element={<LoginSentinel />} />
        </Routes>,
        { initialEntries: ['/flights'] },
      )

      // Assert
      await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
      expect(screen.queryByTestId('flights-route')).toBeNull()
    })
  })
})

describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () => {
  beforeEach(() => {
    // Each test wires its own auth response; nothing global needed.
  })
  afterEach(() => {
    clearBearer()
    vi.useRealTimers()
  })

  describe('FT-P-32 / NFT-SEC-05 — spinner a11y while bootstrap is loading', () => {
    it.fails(
      'spinner element carries role="status" + aria-live="polite" + an accessible name (drift: aria attributes currently missing)',
      async () => {
        // Arrange — keep bootstrap pending forever so the spinner stays mounted.
        server.use(
          http.post('/api/admin/auth/refresh', async () => {
            await new Promise<void>(() => { /* never resolves */ })
            return new HttpResponse(null, { status: 200 })
          }),
        )

        // Act
        renderWithProviders(
          <Routes>
            <Route
              path="/flights"
              element={
                <ProtectedRoute>
                  <FlightsSentinel />
                </ProtectedRoute>
              }
            />
          </Routes>,
          { initialEntries: ['/flights'] },
        )

        // Assert AC-1: the loading element advertises its status role and a
        // localized accessible name (i18n key TBD; for the drift assertion we
        // accept any non-empty accessible name).
        const status = await screen.findByRole('status')
        expect(status).toHaveAttribute('aria-live', 'polite')
        const name = status.getAttribute('aria-label') ?? status.textContent ?? ''
        expect(name.trim().length).toBeGreaterThan(0)
      },
    )

    it('control — spinner renders today as a bare animate-spin div with no aria role (drift seen)', async () => {
      server.use(
        http.post('/api/admin/auth/refresh', async () => {
          await new Promise<void>(() => { /* never resolves */ })
          return new HttpResponse(null, { status: 200 })
        }),
      )
      const { container } = renderWithProviders(
        <Routes>
          <Route
            path="/flights"
            element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
          />
        </Routes>,
        { initialEntries: ['/flights'] },
      )

      // Assert AC-1 evidence: the spinner exists, but is NOT a status role today.
      const spinner = container.querySelector('.animate-spin')
      expect(spinner).not.toBeNull()
      expect(screen.queryByRole('status')).toBeNull()
    })
  })

  describe('FT-P-33 / NFT-RES-04 — 10s loading timeout fallback', () => {
    it.skip(
      'QUARANTINE (no production behavior): after 10s the spinner is replaced with a fallback that offers a retry affordance',
      async () => {
        // When ProtectedRoute gains a timeout (`useEffect` + setTimeout, or a
        // useTimeout hook) and a fallback render path, this test:
        //   1. Mocks bootstrap to never resolve.
        //   2. Renders the ProtectedRoute tree.
        //   3. Advances Vitest fake-timers by 10_000 ms.
        //   4. Asserts the fallback element is present with a retry affordance
        //      (a button / link whose accessible name matches /retry|reload/i).
        // The test is skipped today because no timeout / fallback path exists
        // in src/auth/ProtectedRoute.tsx — asserting absent UI would produce
        // noise. Once the production path lands the assertion shape is below.
        vi.useFakeTimers()
        server.use(
          http.post('/api/admin/auth/refresh', async () => {
            await new Promise<void>(() => { /* never */ })
            return new HttpResponse(null, { status: 200 })
          }),
        )
        renderWithProviders(
          <Routes>
            <Route
              path="/flights"
              element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
            />
          </Routes>,
          { initialEntries: ['/flights'] },
        )
        vi.advanceTimersByTime(10_000)
        const retry = await screen.findByRole('button', { name: /retry|reload/i })
        expect(retry).toBeInTheDocument()
      },
    )

    it('control — bootstrap stuck at >10s today shows ONLY the spinner; no fallback (drift seen)', async () => {
      vi.useFakeTimers()
      server.use(
        http.post('/api/admin/auth/refresh', async () => {
          await new Promise<void>(() => { /* never */ })
          return new HttpResponse(null, { status: 200 })
        }),
      )
      const { container } = renderWithProviders(
        <Routes>
          <Route
            path="/flights"
            element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
          />
        </Routes>,
        { initialEntries: ['/flights'] },
      )
      vi.advanceTimersByTime(10_000)

      // QUARANTINE evidence: still showing the spinner; no retry surface.
      expect(container.querySelector('.animate-spin')).not.toBeNull()
      expect(screen.queryByRole('button', { name: /retry|reload/i })).toBeNull()
    })
  })

  describe('FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights', () => {
    it.skip(
      'QUARANTINE (no production behavior): an authenticated Operator hitting /admin is redirected to /flights',
      async () => {
        // When ProtectedRoute gains a `requirePermission` prop (or wrapper) and
        // the /admin route opts in, this test:
        //   1. Boots auth as op_alice (Operator) with seedPermissions['user-alice']
        //      (which intentionally lacks 'ADMIN_WRITE').
        //   2. Navigates to /admin.
        //   3. Asserts the router settled on /flights, not /admin or /login.
        withUser(opAlice)
        renderWithProviders(
          <Routes>
            <Route
              path="/admin"
              element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
            />
            <Route path="/flights" element={<FlightsSentinel />} />
            <Route path="/login" element={<LoginSentinel />} />
          </Routes>,
          { initialEntries: ['/admin'] },
        )
        await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
        expect(screen.queryByTestId('admin-route')).toBeNull()
      },
    )

    it('control — an authenticated Operator reaches /admin today (no RBAC gate; drift seen)', async () => {
      withUser(opBob) // op_bob lacks 'ADMIN_WRITE' and 'SETTINGS'
      renderWithProviders(
        <Routes>
          <Route
            path="/admin"
            element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
          />
          <Route path="/flights" element={<FlightsSentinel />} />
          <Route path="/login" element={<LoginSentinel />} />
        </Routes>,
        { initialEntries: ['/admin'] },
      )

      // Today the admin sentinel renders — ProtectedRoute does not check
      // permissions, only `user != null`.
      await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
      expect(screen.queryByTestId('flights-route')).toBeNull()
    })

    it('Admin reaches /admin normally (positive control — same path, role permitted)', async () => {
      withUser(adminCarol)
      renderWithProviders(
        <Routes>
          <Route
            path="/admin"
            element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
          />
          <Route path="/flights" element={<FlightsSentinel />} />
        </Routes>,
        { initialEntries: ['/admin'] },
      )
      await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
    })
  })

  describe('FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects', () => {
    it.skip(
      'QUARANTINE (no production behavior): an authenticated user without SETTINGS is redirected away from /settings',
      async () => {
        // When ProtectedRoute gains permission gating, this test:
        //   1. Boots auth as integrator_dave (whose seedPermissions lacks SETTINGS).
        //   2. Navigates to /settings.
        //   3. Asserts the router settled on /flights (or wherever policy says).
        withUser(integratorDave)
        renderWithProviders(
          <Routes>
            <Route
              path="/settings"
              element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
            />
            <Route path="/flights" element={<FlightsSentinel />} />
            <Route path="/login" element={<LoginSentinel />} />
          </Routes>,
          { initialEntries: ['/settings'] },
        )
        await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
        expect(screen.queryByTestId('settings-route')).toBeNull()
      },
    )

    it('control — integrator-dave reaches /settings today (no RBAC gate; drift seen)', async () => {
      withUser(integratorDave)
      renderWithProviders(
        <Routes>
          <Route
            path="/settings"
            element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
          />
          <Route path="/flights" element={<FlightsSentinel />} />
        </Routes>,
        { initialEntries: ['/settings'] },
      )
      await waitFor(() => expect(screen.getByTestId('settings-route')).toBeInTheDocument())
    })
  })
})
