import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { act, useRef } from 'react'
import { server } from '../../tests/msw/server'
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
import { api, getToken, setToken } from '../api'
import { seedBearer, clearBearer } from '../../tests/helpers/auth'

// AZ-457 — Auth & token-handling at the React composition root.
//   FT-P-01  / row 02   — bootstrap refresh sends credentials:'include'
//                          (un-quarantined by AZ-510; bootstrap is now POST
//                           with credentials per the consolidation, so the
//                           `it.fails` wrapper is removed and the assertion
//                           runs as a regression guard)
//   FT-P-03  / row 11   — refresh transparency — children don't unmount;
//                          re-render delta ≤ 1
//   NFT-SEC-01 / row 04  — bearer never written to localStorage/sessionStorage
//                          (over the entire test lifetime, not just at one
//                           snapshot — AC-3)
//   NFT-SEC-02 / rows 05+ — refresh token not exposed via document.cookie at
//                          runtime (the static counterpart lives in
//                           scripts/run-tests.sh)
//
// Black-box discipline: the test imports the public AuthProvider component,
// the public setToken / getToken accessors, and React itself. It never
// reaches into AuthContext's internals (state setters, context value, etc.).
// All assertions are observable at the DOM, network, or storage surface.

interface StorageProbe {
  writes: Array<{ store: 'local' | 'session'; key: string; value: string }>
  values: Array<{ store: 'local' | 'session'; key: string; value: string }>
  cookieReads: number
  cookieWrites: string[]
  restore: () => void
}

function instrumentStorage(): StorageProbe {
  const probe: StorageProbe = {
    writes: [],
    values: [],
    cookieReads: 0,
    cookieWrites: [],
    restore: () => {
      /* installed below */
    },
  }
  const originalLocalSet = Storage.prototype.setItem
  const wrappedSet = function (this: Storage, key: string, value: string) {
    const store: 'local' | 'session' = this === window.localStorage ? 'local' : 'session'
    probe.writes.push({ store, key, value })
    return originalLocalSet.call(this, key, value)
  }
  Storage.prototype.setItem = wrappedSet

  // Patch document.cookie getter/setter on the document instance so reads /
  // writes surface in the probe without escaping jsdom's defaults.
  const cookieDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(document), 'cookie')
  if (cookieDescriptor) {
    Object.defineProperty(document, 'cookie', {
      configurable: true,
      get() {
        probe.cookieReads += 1
        return cookieDescriptor.get?.call(document) ?? ''
      },
      set(v: string) {
        probe.cookieWrites.push(v)
        cookieDescriptor.set?.call(document, v)
      },
    })
  }

  probe.restore = () => {
    Storage.prototype.setItem = originalLocalSet
    if (cookieDescriptor) {
      Object.defineProperty(document, 'cookie', cookieDescriptor)
    }
  }
  return probe
}

function snapshotAllStorageValues(): Array<{ store: 'local' | 'session'; key: string; value: string }> {
  const out: Array<{ store: 'local' | 'session'; key: string; value: string }> = []
  for (let i = 0; i < window.localStorage.length; i += 1) {
    const k = window.localStorage.key(i)!
    out.push({ store: 'local', key: k, value: window.localStorage.getItem(k) ?? '' })
  }
  for (let i = 0; i < window.sessionStorage.length; i += 1) {
    const k = window.sessionStorage.key(i)!
    out.push({ store: 'session', key: k, value: window.sessionStorage.getItem(k) ?? '' })
  }
  return out
}

describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage discipline', () => {
  let probe: StorageProbe

  beforeEach(() => {
    probe = instrumentStorage()
  })

  afterEach(() => {
    probe.restore()
    window.localStorage.clear()
    window.sessionStorage.clear()
    clearBearer()
  })

  describe('AC-4 (AZ-510) — /users/me failure after refresh success clears the bearer', () => {
    it('POST refresh 200 then GET /users/me 401 → setToken(null) + setUser(null) + loading false; console.error fires', async () => {
      // Arrange — refresh succeeds and seeds a bearer; chained /users/me
      // returns 401 (e.g. user record gone server-side after a stale cookie
      // hit). Constraint #4 says the bearer must be cleared so an in-flight
      // re-render does not see (user: null) alongside an active accessToken.
      const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { /* swallow during assert */ })
      let usersMeHits = 0
      server.use(
        http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'mid-flight-bearer' })),
        http.get('/api/admin/users/me', () => {
          usersMeHits += 1
          return new HttpResponse(null, { status: 401 })
        }),
      )

      // Act
      renderWithProviders(<div data-testid="app">app</div>)
      await waitFor(() => expect(usersMeHits).toBeGreaterThanOrEqual(1))
      await waitFor(() => expect(getToken()).toBeNull())

      // Assert — bearer cleared, error logged with diagnostic shape.
      expect(getToken()).toBeNull()
      expect(errorSpy).toHaveBeenCalled()
      const loggedAtLeastOnceWithRefreshOkUserFailed = errorSpy.mock.calls.some(args =>
        typeof args[0] === 'string' && args[0].includes('/users/me failed'),
      )
      expect(loggedAtLeastOnceWithRefreshOkUserFailed).toBe(true)
      errorSpy.mockRestore()
    })
  })

  describe('FT-P-01 (row 02) — bootstrap refresh', () => {
    it("AuthProvider mount sends POST /api/admin/auth/refresh with credentials:'include'", async () => {
      // Arrange — AZ-510 consolidated the bootstrap onto the same wire shape
      // as the 401-retry: POST refresh with credentials:'include', then a
      // chained GET /users/me for the user payload. This test is the
      // regression guard for the credentials:'include' contract and the
      // wire-method (POST vs the previously-broken GET).
      let bootstrapMethod: string | null = null
      let bootstrapCredentials: RequestCredentials | null = null
      let usersMeHits = 0
      server.use(
        http.post('/api/admin/auth/refresh', ({ request }) => {
          bootstrapMethod = request.method
          bootstrapCredentials = request.credentials
          return HttpResponse.json({ token: 'bootstrap-bearer' })
        }),
        http.get('/api/admin/users/me', () => {
          usersMeHits += 1
          return HttpResponse.json({
            id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [],
          })
        }),
      )

      // Act
      renderWithProviders(<div data-testid="app-root">app</div>)
      await waitFor(() => expect(bootstrapCredentials).not.toBeNull())
      await waitFor(() => expect(usersMeHits).toBe(1))

      // Assert — POST + credentials:'include' + chained /users/me.
      expect(bootstrapMethod).toBe('POST')
      expect(bootstrapCredentials).toBe('include')
      expect(usersMeHits).toBe(1)
    })
  })

  describe('FT-P-03 (row 11) — refresh transparency: children stay mounted, re-render delta ≤ 1', () => {
    it('mid-session refresh does not unmount the protected child; re-render delta ≤ 1', async () => {
      // Arrange — a stable child component records its render counter.
      const renderTimes: number[] = []
      function StableChild() {
        const ref = useRef(0)
        ref.current += 1
        renderTimes.push(ref.current)
        return <div data-testid="stable-child">child #{ref.current}</div>
      }
      // Bootstrap (AZ-510 wire shape): POST refresh -> { token }, chained GET
      // /users/me -> user. Await bootstrap settlement BEFORE re-overriding
      // /users/me below — otherwise the 401-retry handler would intercept
      // bootstrap's chained call and the test would fight itself.
      server.use(
        http.post('/api/admin/auth/refresh', () =>
          HttpResponse.json({ token: 'bootstrap-bearer' }),
        ),
        http.get('/api/admin/users/me', () =>
          HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
        ),
      )

      renderWithProviders(<StableChild />)
      await screen.findByTestId('stable-child')
      await waitFor(() => expect(getToken()).toBe('bootstrap-bearer'))
      const renderCountAfterBootstrap = renderTimes.length

      // Force a 401-retry cycle on a downstream authed call. New /users/me
      // handler returns 401 once, then 200 — exercises api/client.ts:73.
      let firstHit = true
      let refreshHits = 0
      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', () => {
          refreshHits += 1
          return HttpResponse.json({ token: 'rotated-bearer' })
        }),
      )

      // Act
      await act(async () => {
        await api.get('/api/admin/users/me')
      })

      // Assert — child stayed mounted (no unmount/remount); render delta ≤ 1.
      expect(screen.getByTestId('stable-child')).toBeInTheDocument()
      expect(refreshHits).toBe(1)
      const reRenderDelta = renderTimes.length - renderCountAfterBootstrap
      expect(reRenderDelta).toBeLessThanOrEqual(1) // row 11 — exact bound
    })
  })

  describe('NFT-SEC-01 (row 04) — bearer never in localStorage / sessionStorage', () => {
    it('over the entire test lifetime: no setItem call, no key/value contains the bearer', async () => {
      // Arrange — full bootstrap + refresh + downstream-authed call lifecycle.
      // AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
      // /users/me. The /users/me handler returns 200 the first time (bootstrap
      // chain), 401 the second time (forces 401-retry), then 200 again (post-
      // retry replay).
      const BEARER = 'leak-trap-bearer-' + Date.now()
      let refreshCallCount = 0
      let usersMeCallCount = 0
      server.use(
        http.post('/api/admin/auth/refresh', () => {
          refreshCallCount += 1
          // Call 1 = bootstrap; subsequent calls = 401-retry rotation. Both
          // are credential-only (no Authorization header), so order is the
          // only discriminator.
          return HttpResponse.json({ token: refreshCallCount === 1 ? BEARER : BEARER + '-rotated' })
        }),
        http.get('/api/admin/users/me', () => {
          usersMeCallCount += 1
          // Bootstrap chain (call 1) -> success; downstream test call (call 2)
          // -> 401 forces a refresh; post-refresh replay (call 3) -> success.
          if (usersMeCallCount === 2) {
            return new HttpResponse(null, { status: 401 })
          }
          return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] })
        }),
      )

      // Act — boot, then drive a refresh+retry cycle, then settle.
      renderWithProviders(<div data-testid="app">app</div>)
      await waitFor(() => expect(getToken()).toBe(BEARER))
      await act(async () => {
        await api.get('/api/admin/users/me')
      })
      await waitFor(() => expect(getToken()).toBe(BEARER + '-rotated'))

      // Assert — across the FULL test lifetime, no Storage.setItem call ever
      // referenced the bearer; no key in either store contains it (AC-3 says
      // "for the duration of the test", not just one snapshot).
      const writesContainingBearer = probe.writes.filter(
        (w) => w.value.includes(BEARER) || w.key.toLowerCase().includes('token') || w.key.toLowerCase().includes('bearer'),
      )
      expect(writesContainingBearer).toEqual([])

      const finalSnapshot = snapshotAllStorageValues()
      const leaked = finalSnapshot.filter((e) => e.value.includes(BEARER))
      expect(leaked).toEqual([])
    })
  })

  describe('NFT-SEC-02 (row 05) — refresh token not exposed via JS-readable document.cookie', () => {
    it('after bootstrap + refresh cycle, document.cookie carries no refresh token', async () => {
      // Arrange — drive a full auth lifecycle. If production code (or any
      // library it brings in) wrote a JS-readable cookie carrying token /
      // refresh material, it would surface in `document.cookie` here.
      // (HttpOnly cookies set by the real admin/ service are invisible to JS;
      // jsdom's MSW responses set no cookies at all unless the test does.)
      // AZ-510 wire shape: bootstrap = POST refresh + chained /users/me; the
      // explicit downstream /users/me call below succeeds without rotation
      // (the rotated-bearer assertion below is a defence-in-depth check —
      // the value never appears anywhere because no rotation is triggered).
      server.use(
        http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'bootstrap-bearer-XYZ' })),
        http.get('/api/admin/users/me', () =>
          HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
        ),
      )

      // Act — bootstrap + an authed call.
      renderWithProviders(<div data-testid="app">app</div>)
      await waitFor(() => expect(getToken()).toBe('bootstrap-bearer-XYZ'))
      await act(async () => {
        await api.get('/api/admin/users/me')
      })

      // Assert — JS-visible cookie jar carries neither bearer value nor any
      // refresh-prefixed cookie name (case-insensitive).
      const visibleCookies = document.cookie
      expect(visibleCookies, 'bearer must not appear in JS-readable cookies').not.toContain('bootstrap-bearer-XYZ')
      expect(visibleCookies, 'rotated bearer must not appear in JS-readable cookies').not.toContain('rotated-bearer-ABC')
      expect(visibleCookies, 'no refresh-named cookie should be JS-visible').not.toMatch(/refresh/i)

      // Defence-in-depth: production code did not write to document.cookie
      // during the cycle (any setter call would have surfaced here).
      expect(probe.cookieWrites).toEqual([])
    })
  })
})

// Type-only import guard — colocated test file does not import private
// AuthContext internals (only the public AuthProvider mount path, surfaced
// indirectly through renderWithProviders).
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _PublicSurface = typeof setToken extends (t: string | null) => void ? true : never
