import anyTest, { type TestFn } from 'ava'
import timekeeper from 'timekeeper'
import { createServer } from 'http'
import { once } from 'events'
import { fetch as undiciFetch, MockAgent, setGlobalDispatcher } from 'undici'
import type { Server } from 'node:net'

const agent = new MockAgent()
agent.disableNetConnect()
setGlobalDispatcher(agent)

const fetch: typeof globalThis.fetch = (...args) =>
  undiciFetch(...(args as Parameters<typeof undiciFetch>)) as any

import {
  jwtVerify,
  SignJWT,
  importJWK,
  createRemoteJWKSet,
  customFetch,
  jwksCache,
  errors,
  type FlattenedJWSInput,
} from '../../src/index.js'

const now = 1604416038

interface WithServer {
  server: Server
}

const test = anyTest as TestFn<WithServer>

test.before(async (t) => {
  agent.disableNetConnect()
  t.context.server = createServer().unref().listen(3000)
  t.context.server.removeAllListeners('request')
  await once(t.context.server, 'listening')
})

test.after(async (t) => {
  agent.enableNetConnect()
  await new Promise((resolve) => t.context.server.close(resolve))
})

test.afterEach(() => {
  agent.disableNetConnect()
})

test.afterEach((t) => {
  t.context.server.removeAllListeners('request')
  agent.assertNoPendingInterceptors()
})

test.afterEach(() => timekeeper.reset())

test.serial('RemoteJWKSet', async (t) => {
  const keys = [
    {
      e: 'AQAB',
      n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw',
      d: 'buWn14TSLtMhJo_ZLWU4bo_WJCoq0xFWm-eodyOz-9YZ5iycGXibcTLKJ8fvOHuj-KysjNhYvTybvqhuagQR08AJabZUM2zrK6zO4bxbHOS-EAKQf27xbAHPnzIIrb5tnivmZr6hXAsxyXWg84ZlzIVCKdXLhQuUIWZF-u_uNVeJSUTDMRVTL2J0mzAGTXqi-yHejapEeLS7lFXDe6cpDnBVXauJfB4GmSUOjxtdAEVW7uGNQJGarGwRz6l3Tpy_xQiYl8e_IrU1N6qAN_HJEBrdgfK7js93RcsxHGbtdnj1ylevZqGFpB1UXrWE4JSz3sJgyXrmKNFFWOCjalMccQ',
      p: '98OCXxur1omXdjfWkDubqkI3xRehVQryIhqt0go-1yLS4Nwa7KyrdAbzTo81bCHN0A-NlmIvHA4YZc8QUHftq65s4nCbb3g_CwTfGCJEVCvoaTO2EE6Pd8VrGu2PVsN4SM52Gc0TNJGS54yUhyCWDTi1onUBEg8gnqpMSoWuaVM',
      q: 'xmaRdSJf4wN5Vse5jiIjZy5jy9veHHhzXxTmW25epr9KTMERUDDCzX0bNbnF8jCvDFN5ebzcEe-9nkWyzJ17wVcJTouEfw8A5pBPcx6Gr8Kd8WIrUjuom4xu-4619kMItoV4j62_nq3p0QUGot_6CgUdq63PCp9Fh-sHv8wViy0',
      dp: '0OCXwbzfYu_-rCCpGFHYi3Jl-BhS4BJpTc02K3SNw-vM4ttNK6jqptfRObLMNAxPqg_iqxy9YKaVdQdbVqu0yF811rVepVw3sf96YatJ9bhKqJ566EaC91ONV1dd16TVfHPq5xeYEGKF-gXvlfgn6J-dqYeAzovIUVt7E_ydrJc',
      dq: 'sYDOnqe0dhyDkNp77ugoGIZujtMVcw9o2SaPujmSwUjfprANV1tozgQiNf0RVk-sLTD5u6r2ka2WTmY5Q8uaDy5Zi0ZTsoGv4pg2HN6wzcsnF_EmpRnvDcuk97eEoOD0iKf9Zz6h88vRJ0qB13Lf99r_4rtMQ0qgIKxscHKcy7k',
      qi: '7uvpgL15VFjd_zjhU0fPVeTzAa6Vg3P3Q2v5DLwLkAIlQDqF50maTYztxtxssVNJtEMIxKefwrmGkyVCXNhrGHZDoj7wj-2o0k878bQqtltCO2TPm9TSYZgW7dR3ji0t4Msc5DcrQL002M_Vxqr9MAunQcAsnulRTepQM2n-aOc',
      kty: 'RSA',
      kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM',
    },
    {
      e: 'AQAB',
      n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ',
      d: 'LXGufKH6IBb4pUKh-iKX-ba1dBSGOkenUTHCd5STUG_JX3gsWUC5NPeTqrQzHkjV3otZytN3TgyZkr-QXDurEEtotD6Y1Ma85aljkuNfKTWWWoE1KwNmPZp0BQRB8lfGjmrNcC49tpw6owX4GvbqId_ifQupN32rY3t4qfq9xpO9SAqZF0oUMoS7xE0zChsCJmNYpD9jx87p5Vud1naeaZPlvwWW0ITV4kp2zjYSbBh5DkI52rSrGjkuzlsJ_lKJk5YB557OHhN9XTRBnjqlwwWevh6QAoUivqpcelcplgmfxTHoII1opovYXn8AVt-DbGSO_7LLJ0Sw9sJR5GAqcQ',
      p: '9RdDqZ3O73lH6nWUGi0abQRRfgvj-HM0zP7GSDQ185l-ZByletl1VuJ86qYJTUY8Q3Gagv6_eXmQMo_14-0wT_FPUMiTMYsjw5QNgFgjlJTM1AayS_U5ddix_Ut7Kti7EXgM0gsavsIazv2-xwCrFzD4sa-t2FWELzzWxgt8wbs',
      q: '2kX8MN8ItGnn7NnPx-0iqe8kkhy5s9gJRiD3mxN9E6xzRCnf488yhc3aBwI9kZzQtV0XVjX5VhCws5vnJv9b7KA8NATDhpGNrqy2h9ncmsjTTjafUg3jb6QG08kIKDR-A97Mc-MJbIUNzYs10BAG4z9wk7t1bdo4gZJEvjiXVHM',
      dp: 'Ahggy-I9Um6G3soCafbYsvXGfH09hXH2kYnbx-IqU9qL6e8UuugAyK1Gw_qHOdHP0gO2fkgO-sq_IK96OmhccVJuixIrr9CwjYtGUkJui2Z6GZW1EFEYHJmta6ypcMRJVOzhrynJILgn4nzolGq9C4WvmlUV9zND3eN3MloGxuE',
      dq: 'uXKWlusX2TjVvM0-FO2r8tdkqeNP_7XAA15FIPOI5Cszb6loOIQ0t6wy3puPteSXClBCYJPQ-MeLab4-wUpaTovBOq0FdpK53ruNBZUbMkMIDL6p1CxKnPKufkeh747RtfYYnSk7O4E8PfNV0CWdxHuE6W9ukNvEAIpGb5tjL3M',
      qi: '3BLQ03cHEmO8nUT7U8M_H_JciEWAH8XWh_9nihIhXzLKYbNmWM16Ah0F9DUg0GPeiG7e_08ZJ4X3oK1bHnnXdns6NSOEoULWfHl5LUY5PoFPYaBDy3f6td2SCTE83p1YzegXKysWEk1snA2ROq4UEfz1vL8v64RtwR3SvNrAyOI',
      kty: 'RSA',
      alg: 'RS256',
      kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE',
    },
    {
      crv: 'P-256',
      x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
      y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
      d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
      kty: 'EC',
      kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw',
    },
  ]
  const jwks = {
    keys: [
      {
        e: 'AQAB',
        n: 'wAR7gpvDJx2cUR15R1gyBYxEXanhOIDzk7evzadBpNCEpf6HA6utqMrf8dZ3EXSslKPSPBD5Qrz63kc2u8y7NqzwJQIi_i5xR6AxAyWLG3_kOHBwxnct6talLCZqgr8pDwnyP1BPnIaNf2hZxgS-UZbHCAVycd1n2qCdyb4FzFhcaNtiOLg5VSfgvtOdhHQlDXW-DBvwatpd9HzzTP6l5MZRyQ-N_AoGbIfhNCZRUfnb-A8IBPSqXBWN4TEpt-0yHAOIhWnSpu66AYE4f1efZdHVFCTQZ13e5bS-5RQra4pfmGqU9hog1j1SpHnDTia-s__qGi43rev2MqzY-qeUlw',
        kty: 'RSA',
        kid: 'ZuLUAgyr6RQV3ERjDukHzOO_90rVbrPiE1vD_HtPFuM',
      },
      {
        e: 'AQAB',
        n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ',
        alg: 'RS256',
        kty: 'RSA',
        kid: 'hJU8GvYjtifxLVuBDSNmhLBF19wQHaZvQhfpT3wKzpE',
      },
      {
        crv: 'P-256',
        x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
        y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
        kty: 'EC',
        kid: 'a-5xuiQoRqlLBtec9jRpXoGTVOP10SGnj2Und0CHHxw',
      },
      {
        e: 'AQAB',
        n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ',
        alg: 'RS256',
        kty: 'RSA',
        use: 'enc',
      },
      {
        e: 'AQAB',
        n: '0PjQVV2ZAT27Y0h7hfAWWcnPetORCvR1_gHvEUxtlrlnhZia7utHl7BCJH9HP17YHMMBeeEkmUDflYoUL6MDl4DYHgVDq8jZfu1pxH1XqrpeswqOVoReknEe0F5kRt_mPtIoShI2Qv-pxGAw392akAXTirVRLL4Fn_0Oiifxp182P7eTPy41rlKDevLHuKHBZzzaes_33YE2epY2YCLp9k3mZ-tJEei2qiq0T1fERQicGUL8kppOnz0cDNuKRBHyYtXWhjhuDQ8OZQHNLfte9cqzTJMJ4Leu4MGjikSZMsk-_aRFnXtYHH0orwY-giSenRnwNaReAXaR1Px9ReljAQ',
        alg: 'RS256',
        kty: 'RSA',
        use: 'sig',
        key_ops: [],
      },
    ],
  }

  agent
    .get('https://as.example.com')
    .intercept({
      path: '/jwks',
      headers(headers) {
        return !!headers['user-agent'].match(/jose\/v\d+\.\d+\.\d+/)
      },
    })
    .reply(200, jwks)

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  // Signed JWT
  {
    const [jwk] = keys
    const key = await importJWK({ ...jwk, alg: 'PS256' })
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'PS256', kid: jwk.kid }).sign(key)
    await t.notThrowsAsync(async () => {
      const { key: resolvedKey } = await jwtVerify(jwt, JWKS)
      t.truthy(resolvedKey)
      t.true(resolvedKey instanceof CryptoKey)
      t.is((resolvedKey as CryptoKey).type, 'public')
    })
  }
  {
    const [jwk] = keys
    const key = await importJWK({ ...jwk, alg: 'RS256' })
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'RS256' }).sign(key)
    let error: errors.JWKSMultipleMatchingKeys = await t.throwsAsync(jwtVerify(jwt, JWKS), {
      code: 'ERR_JWKS_MULTIPLE_MATCHING_KEYS',
      message: 'multiple matching keys found in the JSON Web Key Set',
    })

    // async iterator (KeyLike)
    {
      const cache = new WeakSet()
      for await (const ko of error) {
        t.like(ko, { type: 'public' })
        cache.add(ko)
      }
      error = await t.throwsAsync(jwtVerify(jwt, JWKS), {
        code: 'ERR_JWKS_MULTIPLE_MATCHING_KEYS',
        message: 'multiple matching keys found in the JSON Web Key Set',
      })
      let i = 0
      for await (const ko of error) {
        i++
        t.true(cache.has(ko))
      }
      t.is(i, 2)
    }
  }
  {
    const [, jwk] = keys
    const key = await importJWK({ ...jwk, alg: 'PS256' })
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'PS256', kid: jwk.kid }).sign(key)
    await t.throwsAsync(jwtVerify(jwt, JWKS), {
      code: 'ERR_JWKS_NO_MATCHING_KEY',
      message: 'no applicable key found in the JSON Web Key Set',
    })
  }
  {
    const [, , jwk] = keys
    const key = await importJWK({ ...jwk, alg: 'ES256' })
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256' }).sign(key)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
})

test.serial('refreshes the JWKS once off cooldown', async (t) => {
  timekeeper.freeze(now * 1000)
  const jwk = {
    crv: 'P-256',
    x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
    y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
    d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
    kty: 'EC',
  }
  const jwks = {
    keys: [
      {
        crv: 'P-256',
        x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
        y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
        kty: 'EC',
        kid: 'one',
      },
    ],
  }
  const mockAgent = agent.get('https://as.example.com')

  mockAgent.intercept({ path: '/jwks' }).reply(200, jwks)

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  const key = await importJWK({ ...jwk, alg: 'ES256' })
  {
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256', kid: 'one' }).sign(key)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
  {
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256', kid: 'two' }).sign(key)
    await t.throwsAsync(jwtVerify(jwt, JWKS), {
      code: 'ERR_JWKS_NO_MATCHING_KEY',
      message: 'no applicable key found in the JSON Web Key Set',
    })
    jwks.keys[0].kid = 'two'
    mockAgent.intercept({ path: '/jwks' }).reply(200, jwks)
    timekeeper.travel((now + 30) * 1000)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
})

test.serial('createRemoteJWKSet manual reload', async (t) => {
  timekeeper.freeze(now * 1000)
  const jwk = {
    crv: 'P-256',
    x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
    y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
    d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
    kty: 'EC',
  }
  const jwks = {
    keys: [
      {
        crv: 'P-256',
        x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
        y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
        kty: 'EC',
        kid: 'one',
      },
    ],
  }

  const mockAgent = agent.get('https://as.example.com')

  mockAgent.intercept({ path: '/jwks' }).reply(200, jwks)

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  t.false(JWKS.coolingDown)
  t.false(JWKS.fresh)
  t.false(JWKS.reloading)
  t.is(JWKS.jwks(), undefined)
  const key = await importJWK({ ...jwk, alg: 'ES256' })
  {
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256', kid: 'two' }).sign(key)
    await t.throwsAsync(jwtVerify(jwt, JWKS), {
      code: 'ERR_JWKS_NO_MATCHING_KEY',
      message: 'no applicable key found in the JSON Web Key Set',
    })
    jwks.keys[0].kid = 'two'
    mockAgent.intercept({ path: '/jwks' }).reply(200, jwks)
    t.true(JWKS.coolingDown)
    t.true(JWKS.fresh)
    t.false(JWKS.reloading)
    t.notDeepEqual(JWKS.jwks(), jwks)
    const reload = JWKS.reload()
    t.true(JWKS.reloading)
    await reload
    t.true(JWKS.coolingDown)
    t.true(JWKS.fresh)
    t.false(JWKS.reloading)
    t.deepEqual(JWKS.jwks(), jwks)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    JWKS.jwks()!.keys = []
    t.deepEqual(JWKS.jwks(), jwks)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
})

test.serial('an older concurrent workerd reload cannot overwrite a newer JWKS', async (t) => {
  const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebSocketPair')
  Object.defineProperty(globalThis, 'WebSocketPair', {
    configurable: true,
    value: class {},
  })

  const responders: ((response: Response) => void)[] = []
  const controlledFetch = () =>
    new Promise<Response>((resolve) => {
      responders.push(resolve)
    })

  try {
    const JWKS = createRemoteJWKSet(new URL('https://as.example.com/jwks'), {
      [customFetch]: controlledFetch,
    })

    const older = JWKS.reload()
    const newer = JWKS.reload()

    t.is(responders.length, 2)
    responders[1](
      new Response(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'newer' }] }), { status: 200 }),
    )
    await newer
    t.is(JWKS.jwks()!.keys[0].kid, 'newer')

    responders[0](
      new Response(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'older' }] }), { status: 200 }),
    )
    await older
    t.is(JWKS.jwks()!.keys[0].kid, 'newer')

    responders.length = 0
    const Recovery = createRemoteJWKSet(new URL('https://as.example.com/jwks'), {
      [customFetch]: controlledFetch,
    })
    const recoverable = Recovery.reload()
    const failing = Recovery.reload()

    responders[0](
      new Response(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'recovered' }] }), { status: 200 }),
    )
    await recoverable
    t.true(Recovery.reloading)
    t.is(Recovery.jwks()!.keys[0].kid, 'recovered')

    responders[1](new Response(null, { status: 500 }))
    await t.throwsAsync(failing, {
      message: 'Expected 200 OK from the JSON Web Key Set HTTP response',
    })
    t.false(Recovery.reloading)
    t.is(Recovery.jwks()!.keys[0].kid, 'recovered')
  } finally {
    if (descriptor) {
      Object.defineProperty(globalThis, 'WebSocketPair', descriptor)
    } else {
      Reflect.deleteProperty(globalThis, 'WebSocketPair')
    }
  }
})

test.serial('refreshes the JWKS once stale', async (t) => {
  timekeeper.freeze(now * 1000)
  const jwk = {
    crv: 'P-256',
    x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
    y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
    d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
    kty: 'EC',
  }
  const jwks = {
    keys: [
      {
        crv: 'P-256',
        x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
        y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
        kty: 'EC',
        kid: 'one',
      },
    ],
  }

  agent.get('https://as.example.com').intercept({ path: '/jwks' }).reply(200, jwks).times(2)

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { cacheMaxAge: 60 * 10 * 1000, [customFetch]: fetch })
  const key = await importJWK({ ...jwk, alg: 'ES256' })
  {
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256', kid: 'one' }).sign(key)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    timekeeper.travel((now + 60 * 10) * 1000)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
})

test.serial('can be configured to never be stale', async (t) => {
  timekeeper.freeze(now * 1000)
  const jwk = {
    crv: 'P-256',
    x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
    y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
    d: 'XikZvoy8ayRpOnuz7ont2DkgMxp_kmmg1EKcuIJWX_E',
    kty: 'EC',
  }
  const jwks = {
    keys: [
      {
        crv: 'P-256',
        x: 'fqCXPnWs3sSfwztvwYU9SthmRdoT4WCXxS8eD8icF6U',
        y: 'nP6GIc42c61hoKqPcZqkvzhzIJkBV3Jw3g8sGG7UeP8',
        kty: 'EC',
        kid: 'one',
      },
    ],
  }

  agent.get('https://as.example.com').intercept({ path: '/jwks' }).reply(200, jwks)

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { cacheMaxAge: Infinity, [customFetch]: fetch })
  const key = await importJWK({ ...jwk, alg: 'ES256' })
  {
    const jwt = await new SignJWT().setProtectedHeader({ alg: 'ES256', kid: 'one' }).sign(key)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
    timekeeper.travel((now + 60 * 10) * 1000)
    await t.notThrowsAsync(jwtVerify(jwt, JWKS))
  }
})

test('remote JWKS duration options reject NaN', (t) => {
  const url = new URL('https://as.example.com/jwks')

  for (const option of ['timeoutDuration', 'cooldownDuration', 'cacheMaxAge'] as const) {
    t.throws(() => createRemoteJWKSet(url, { [option]: NaN }), {
      instanceOf: TypeError,
    })
  }
})

test('remote JWKS timeoutDuration must be a non-negative integer', (t) => {
  const url = new URL('https://as.example.com/jwks')

  for (const timeoutDuration of [-1, 0.5, Infinity, -Infinity]) {
    t.throws(() => createRemoteJWKSet(url, { timeoutDuration }), {
      instanceOf: TypeError,
    })
  }

  t.notThrows(() => createRemoteJWKSet(url, { timeoutDuration: 0 }))
})

test('remote JWKS duration options are read once', (t) => {
  const url = new URL('https://as.example.com/jwks')

  for (const [option, value] of [
    ['timeoutDuration', 0],
    ['cooldownDuration', 30_000],
    ['cacheMaxAge', 600_000],
  ] as const) {
    let reads = 0
    const options = {} as Record<string, unknown>
    Object.defineProperty(options, option, {
      enumerable: true,
      get() {
        reads++
        return value
      },
    })

    t.notThrows(() => createRemoteJWKSet(url, options))
    t.is(reads, 1)
  }
})

test('remote JWKS validates a single cache snapshot', (t) => {
  timekeeper.freeze(now * 1000)
  const url = new URL('https://as.example.com/jwks')
  const jwks = { keys: [] }
  let timestampReads = 0
  let jwksReads = 0
  const cache = Object.defineProperties(
    {},
    {
      uat: {
        enumerable: true,
        get() {
          timestampReads++
          return timestampReads === 1 ? now * 1000 : NaN
        },
      },
      jwks: {
        enumerable: true,
        get() {
          jwksReads++
          return jwksReads === 1 ? jwks : { keys: [null] }
        },
      },
    },
  )

  const resolver = createRemoteJWKSet(url, { [jwksCache]: cache })
  t.deepEqual(resolver.jwks(), jwks)
  t.is(timestampReads, 1)
  t.is(jwksReads, 1)

  for (const uat of [NaN, Infinity, now * 1000 - 600_000]) {
    const stale = createRemoteJWKSet(url, { [jwksCache]: { uat, jwks } })
    t.is(stale.jwks(), undefined)
  }
})

test.serial('throws on invalid JWKSet', async (t) => {
  const mockAgent = agent.get('https://as.example.com')

  mockAgent.intercept({ path: '/jwks' }).reply(200, 'null')
  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JWKS_INVALID',
    message: 'JSON Web Key Set malformed',
  })

  mockAgent.intercept({ path: '/jwks' }).reply(200, {})
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JWKS_INVALID',
    message: 'JSON Web Key Set malformed',
  })

  mockAgent.intercept({ path: '/jwks' }).reply(200, { keys: null })
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JWKS_INVALID',
    message: 'JSON Web Key Set malformed',
  })

  mockAgent
    .intercept({ path: '/jwks' })

    .reply(200, { keys: [null] })
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JWKS_INVALID',
    message: 'JSON Web Key Set malformed',
  })

  mockAgent.intercept({ path: '/jwks' }).reply(404)
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JOSE_GENERIC',
    message: 'Expected 200 OK from the JSON Web Key Set HTTP response',
  })

  mockAgent.intercept({ path: '/jwks' }).reply(200, '{')
  await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput), {
    code: 'ERR_JOSE_GENERIC',
    message: 'Failed to parse the JSON Web Key Set HTTP response as JSON',
  })
})

test.serial('can have headers configured', async (t) => {
  agent
    .get('https://as.example.com')
    .intercept({
      path: '/jwks',
      headers: {
        'x-custom': 'foo',
      },
    })
    .reply(200, 'null')

  const url = new URL('https://as.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { headers: { 'x-custom': 'foo' }, [customFetch]: fetch })
  await JWKS().catch(() => {})
  t.pass()
})

test('handles ENOTFOUND', async (t) => {
  agent.enableNetConnect()
  const url = new URL('https://op.example.com/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  const err = await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput))
  t.true(err instanceof Error)
  t.true(err.cause instanceof Error && 'code' in err.cause && err.cause.code === 'ENOTFOUND')
})

test('handles ECONNREFUSED', async (t) => {
  agent.enableNetConnect()
  const url = new URL('http://localhost:3001/jwks')
  const JWKS = createRemoteJWKSet(url, { [customFetch]: fetch })
  const err = await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput))
  t.true(err instanceof Error)
  t.true(err.cause instanceof Error && 'code' in err.cause && err.cause.code === 'ECONNREFUSED')
})

test.serial('handles a timeout', async (t) => {
  t.timeout(1000)
  agent.enableNetConnect()
  const url = new URL('http://localhost:3000/jwks')
  const JWKS = createRemoteJWKSet(url, {
    timeoutDuration: 500,
    [customFetch]: fetch,
  })
  const err = await t.throwsAsync(JWKS({ alg: 'RS256' }, {} as FlattenedJWSInput))
  t.true(err instanceof errors.JWKSTimeout)
})
