import { afterEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../tests/msw/server'
import { api, getToken, setToken } from './client'
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
import { seedNavigateToLogin } from '../../tests/helpers/navigate'

// AZ-457 — Auth & token-handling at the apiClient surface.
//   FT-P-02  / rows 03, 12   — 401 → refresh → retry sequence (fast profile)
//   NFT-SEC-04 / row 06      — credentials:'include' on the cookie-bound refresh
//   NFT-PERF-02 / row 12     — exactly one /auth/refresh per refresh cycle
//   NFT-RES-01 / rows 03, 11 — apiClient half of transparent recovery
//                              (render stability lives in AuthContext.test.tsx)
//   NFT-RES-08               — expired refresh cookie → setNavigateToLogin spy fires
//
// FT-P-01 (bootstrap refresh credentials:'include') exercises the React
// composition root (<AuthContext>'s mount-time fetch) and lives in
// src/auth/AuthContext.test.tsx — the bootstrap path goes through api.get()
// today, which does NOT thread credentials. Row 02 is `quarantined` until
// the Step 4 bootstrap fix lands; the inverted assertion lives next to its
// system-under-test.
//
// Black-box discipline (per AZ-457 AC-2): we only import the three public
// accessors `setToken / getToken / setNavigateToLogin` plus the `api` wrapper
// itself. We do NOT reach into request() / refreshToken() / handleResponse().
// Outbound requests are observed via MSW `server.use(...)` interception.

describe('AZ-457 / src/api/client.ts — auth & token handling', () => {
  afterEach(() => {
    clearBearer()
  })

  describe('FT-P-02 (rows 03, 12) — 401 → refresh → retry sequence', () => {
    it('refreshes once, retries the original request, returns 200', async () => {
      // Arrange
      seedBearer('expiring-bearer')
      let getHits = 0
      let refreshHits = 0
      const observedAuthHeaders: Array<string | null> = []

      server.use(
        http.get('/api/admin/users/me', ({ request }) => {
          getHits += 1
          observedAuthHeaders.push(request.headers.get('Authorization'))
          if (getHits === 1) {
            return new HttpResponse(null, { status: 401 })
          }
          return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
        }),
        http.post('/api/admin/auth/refresh', () => {
          refreshHits += 1
          return HttpResponse.json({ token: 'rotated-bearer' })
        }),
      )

      // Act
      const me = await api.get<{ id: string; email: string }>('/api/admin/users/me')

      // Assert — sequence per row 03 + count per row 12.
      expect(refreshHits).toBe(1)
      expect(getHits).toBe(2)                     // first 401, then retry
      expect(observedAuthHeaders[0]).toBe('Bearer expiring-bearer')
      expect(observedAuthHeaders[1]).toBe('Bearer rotated-bearer')
      expect(me.id).toBe('user-alice')
      expect(getToken()).toBe('rotated-bearer')
    })

    it('does NOT refresh when there is no bearer (no in-flight session)', async () => {
      // Arrange — no seedBearer call → getToken() === null.
      let refreshHits = 0
      server.use(
        http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
        http.post('/api/admin/auth/refresh', () => {
          refreshHits += 1
          return HttpResponse.json({ token: 'should-not-be-issued' })
        }),
      )

      // Act + Assert — request() short-circuits the refresh branch when
      // accessToken is null (refreshing without a session would be a security
      // anti-pattern).
      await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/)
      expect(refreshHits).toBe(0)
    })
  })

  describe('NFT-SEC-04 (row 06) — credentials:\'include\' on the cookie-bound refresh', () => {
    it('the 401-recovery POST /auth/refresh carries credentials:\'include\'', async () => {
      // Arrange
      seedBearer('expiring-bearer')
      let refreshCredentials: RequestCredentials | null = null
      let refreshMethod: string | null = null
      server.use(
        http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
        http.post('/api/admin/auth/refresh', ({ request }) => {
          refreshCredentials = request.credentials
          refreshMethod = request.method
          return new HttpResponse(null, { status: 401 })
        }),
      )
      const navSpy = seedNavigateToLogin()

      // Act
      await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired')

      // Assert
      expect(refreshMethod).toBe('POST')
      expect(refreshCredentials).toBe('include')
      expect(navSpy).toHaveBeenCalledTimes(1)
    })

    // The broader "every authed fetch" claim is `quarantined` per row 02 in
    // results_report.md. Today the apiClient does NOT thread credentials onto
    // non-refresh requests; only the cookie-bound refreshToken() helper does.
    // The inverted assertion below documents the divergence so a future fix
    // is a deliberate decision (the test starts failing the day every authed
    // fetch carries credentials:'include' — at which point the it.fails
    // wrapper is removed in the same commit as the production fix).
    it.fails('every authed apiClient fetch carries credentials:\'include\' (quarantined — Step 4 bootstrap fix pending)', async () => {
      // Arrange
      seedBearer('test-bearer-default')
      const credentialsByUrl: Record<string, RequestCredentials> = {}
      server.use(
        http.all('/api/admin/*', ({ request }) => {
          credentialsByUrl[request.url] = request.credentials
          return new HttpResponse(null, { status: 204 })
        }),
      )

      // Act
      await api.get('/api/admin/users/me')

      // Assert — intentionally fails today; row 02 quarantined.
      expect(Object.values(credentialsByUrl).every((c) => c === 'include')).toBe(true)
    })
  })

  describe('NFT-PERF-02 (row 12) — exactly one refresh round trip per cycle', () => {
    it('a single 401-retry cycle issues exactly one /auth/refresh', async () => {
      // Arrange
      seedBearer('expiring-bearer')
      let refreshHits = 0
      const status401Once = vi.fn<() => Response>(() => new HttpResponse(null, { status: 401 }) as Response)
      let firstGet = true
      server.use(
        http.get('/api/admin/users/me', () => {
          if (firstGet) {
            firstGet = false
            return status401Once()
          }
          return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
        }),
        http.post('/api/admin/auth/refresh', () => {
          refreshHits += 1
          return HttpResponse.json({ token: 'one-time-rotation' })
        }),
      )

      // Act
      await api.get('/api/admin/users/me')

      // Assert
      expect(refreshHits).toBe(1)
      expect(status401Once).toHaveBeenCalledTimes(1)
    })

    it('two parallel authed requests, both 401 → still one refresh per call (no global de-dupe expected)', async () => {
      // Arrange — Today's client.ts does NOT coalesce parallel refreshes. Each
      // 401 in-flight produces its own refresh round trip. This test pins the
      // current behavior so a future "race-coalescing" change is a deliberate
      // decision (one-cycle == one-refresh per row 12; cycles are per call).
      seedBearer('expiring-bearer')
      const tokens = ['rot1', 'rot2']
      let refreshHits = 0
      const seenAuthHeaders: string[] = []
      server.use(
        http.get('/api/admin/users/me', ({ request }) => {
          const h = request.headers.get('Authorization')
          seenAuthHeaders.push(h ?? '<none>')
          if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 })
          return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
        }),
        http.get('/api/admin/users', ({ request }) => {
          const h = request.headers.get('Authorization')
          seenAuthHeaders.push(h ?? '<none>')
          if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 })
          return HttpResponse.json({ items: [], totalCount: 0, page: 1, pageSize: 10 })
        }),
        http.post('/api/admin/auth/refresh', () => {
          const t = tokens[refreshHits] ?? 'rot-fallback'
          refreshHits += 1
          return HttpResponse.json({ token: t })
        }),
      )

      // Act
      await Promise.all([api.get('/api/admin/users/me'), api.get('/api/admin/users')])

      // Assert — two independent cycles → two refreshes (no coalescing).
      expect(refreshHits).toBe(2)
      // Both retries reissued with a non-stale bearer.
      expect(seenAuthHeaders.filter((h) => h === 'Bearer expiring-bearer')).toHaveLength(2)
    })
  })

  describe('NFT-RES-01 (rows 03, 11) — apiClient half of transparent recovery', () => {
    it('callers observe a 200 response with no error surface after refresh+retry', async () => {
      // Arrange — render-stability half lives in AuthContext.test.tsx; this is
      // strictly the apiClient observable: from the caller's perspective, the
      // 401 was invisible.
      seedBearer('expiring-bearer')
      let firstHit = true
      server.use(
        http.get('/api/admin/users/me', () => {
          if (firstHit) {
            firstHit = false
            return new HttpResponse(null, { status: 401 })
          }
          return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
        }),
        http.post('/api/admin/auth/refresh', () =>
          HttpResponse.json({ token: 'transparent-recovery' }),
        ),
      )

      // Act
      const result = await api.get<{ id: string }>('/api/admin/users/me')

      // Assert — caller saw success, never an exception.
      expect(result.id).toBe('user-alice')
      // Side-effect: bearer was silently rotated (rows 03, 11).
      expect(getToken()).toBe('transparent-recovery')
    })
  })

  describe('NFT-RES-08 — expired refresh cookie → setNavigateToLogin spy fires', () => {
    it('clears bearer and invokes navigateToLogin spy when refresh returns 401', async () => {
      // Arrange
      seedBearer('expired-bearer')
      const navSpy = seedNavigateToLogin()
      server.use(
        http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
        http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
      )

      // Act + Assert
      await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired')
      expect(navSpy).toHaveBeenCalledTimes(1)
      expect(navSpy).toHaveBeenCalledWith()           // AC-4 — no args
      expect(getToken()).toBeNull()                    // bearer cleared
    })

    it('does not navigate or clear bearer when 401 occurs without a session', async () => {
      // Arrange — refresh-bridge invariant: navigate only after a refresh
      // attempt failed (not on every 401).
      const navSpy = seedNavigateToLogin()
      server.use(
        http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
      )

      // Act + Assert
      await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/)
      expect(navSpy).not.toHaveBeenCalled()
      expect(getToken()).toBeNull()
    })
  })
})

// FT-P-01 (row 02) bootstrap-credentials assertion lives in
// src/auth/AuthContext.test.tsx because it observes <AuthProvider>'s
// mount-time fetch. Splitting it out keeps the system-under-test obvious.

// Compile-time guard — the test never imports a private symbol from client.ts.
type _PublicSurface = typeof setToken extends (t: string | null) => void ? true : never
const _checkPublicSurface: _PublicSurface = true
void _checkPublicSurface
