import crypto from 'node:crypto'
import { jest } from '@jest/globals'
import { Hono } from 'hono'
import jwt from 'jsonwebtoken'
import * as oauth2 from 'oauth4webapi'

const MOCK_ISSUER = 'https://accounts.google.com'
const MOCK_CLIENT_ID = 'CLIENT_ID_001'
const MOCK_CLIENT_SECRET = 'CLIENT_SECRET_001'
const MOCK_REDIRECT_URI = 'http://localhost/callback'
const MOCK_SUBJECT = 'USER_ID_001'
const MOCK_EMAIL = 'user001@example.com'
const MOCK_NAME = 'John Doe'
const MOCK_STATE = crypto.randomBytes(16).toString('hex') // 32 bytes
const MOCK_NONCE = crypto.randomBytes(16).toString('hex') // 32 bytes
const MOCK_AUTH_SECRET = crypto.randomBytes(16).toString('hex') // 32 bytes
const MOCK_AUTH_EXPIRES = '3600'
const MOCK_ID_TOKEN = jwt.sign(
  {
    iss: MOCK_ISSUER,
    aud: MOCK_CLIENT_ID,
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    name: MOCK_NAME,
    exp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
    nonce: MOCK_NONCE,
  },
  `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDDp5RtVoTDMre1
HZrPhMr3ic1fQPqRWnKs6f27DoBxA8JOsaHE15ApnLBlDLWKnoLoCNrHCuYGoh/+
WQuxS5LtyZb7Goe1DXjdoEohLjZS1kW0+PgDRCpzon1XHdjo5LdPV+ImhxSxeIFd
vn4NDEhQa5uKKCSZblvz3PgKR36AEM1313qcewgt5YgkQAvaBZFDI5C+FId8w8hf
rYL0W9GPGu4D/gfXHi74habcSni/HkEtRaqsnDh6JAi6pGZNeUnzeNHTcfFqj0ce
SSYTQNJhLqFrRUCLa9kQSOMcxRFBiUAcRGgjXiB4r9vZpIa1H3RGUNf2wOOxVxLF
dIl7dEzrAgMBAAECggEASjF+I4gviCXvbArx7ceZgA0NiBWH7x6xZcjFou1432Jh
iJ3rjk2AKYd1jJwpK4u4cG0LKXeEivdn0nfJ602RRgKv8kC5PXsCXmiuM67mgrsm
a94Njo+G2ZrAlQyIeKhiqv/Ujm+i9TmRNQ9LlX8W3QgxT06xsk0bKXqdxKgf3EfY
MUAQkD2sH7i/Wn2fBj9b6wdTsWQC7SRC7UgTvadvNoinILVyZxwjYY/3BZQRbMUm
687oCLSBeNdixxF9Ip1uvtNPBap6lvkZp1U9y1iteSftcLYd82ZvTvc4qjB800XU
RQRSkF5VddtHgT2kManF3hGjJHKeHsCaY6VyLxn5IQKBgQD42QtVweayQCTgL2VO
V+/SpP68hLTO4sfUz2FCuaW4F+6wvs0D3NUw7ZheCtuxr3enoIr5mrYx3BdR79ge
wQYBppofzXPXPKPQFtmHT3cwLXpwB6knyEMyJ3WpBVAPgcoJbYUOIRcO2eBgMxGR
9XMOU9FKLAsZ5TgDlJqd5pubZwKBgQDJRydu5OYKDtSRLbSxhOdJFF2fBb+ZcIAM
7exmaFUjQHSTiYr5DYraDdvue7JcvxIQFWJFCYdRGacRX9Rvjnvr1gRBHVb5+/FP
OLowCzWz+7F86MkQYd9SgmhD7MIG8XPlxR13NyIMglS49O6euNrB6oKCEYWDE8nB
ZS6TUSWT3QKBgQCs/nYa0AmIsX7xOwG6TPe0AG/2rmrjyFQTZXe/4z+Jk1mkFYCA
xuyObx4VgoboJ4uPRNRYYW13jAHKPGqKNrXuP9u1cCav4sAe0UO4BU5ed78+UpUN
yvKr0zLApajanufNVg3BnM9iy6RoPBhi17d8plhAsA2nmuot0wkJ7F8Q0QKBgECo
f+1q0M84VmbQ1PQV6qqaRTz5fsRO1IPSxpdbOsZZRVnD3IYHKKzFuPoSeIi8xJOw
GuJsnjCaWgYFz9uKXRq0pKc6Qp+JpMo7Qex/HWBVIX4r1bNSjYgW5mGzo9zRIdcV
DFMoveJg19CWtjT80yFqMUSRVl92MuDSnTSr47NtAoGBAJGu7TU6+qRGN6Bp38+A
jc1vz90U86BT9PnNbCzmVP3xdRfLLGZm6JWCVYNt1mm3/KAyK8bkviw4bHilcIMj
HfRCXKFdIfHsYcxAhUkKDpNguFv06xjbrlP6vPkqkp/4Td4sGQ8dAVmrcwpdv56o
UlMwcdSLCKw3qpSJOA08k7pz
-----END PRIVATE KEY-----`,
  { algorithm: 'RS256' }
)
const MOCK_JWT_ACTIVE_SESSION = jwt.sign(
  {
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    rtk: 'DUMMY_REFRESH_TOKEN',
    rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
    ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
  },
  MOCK_AUTH_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' }
)
const MOCK_JWT_TOKEN_EXPIRED_SESSION = jwt.sign(
  {
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    rtk: 'DUMMY_REFRESH_TOKEN',
    rtkexp: Math.floor(Date.now() / 1000) - 1, // expired
    ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
  },
  MOCK_AUTH_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' }
)
const MOCK_JWT_EXPIRED_SESSION = jwt.sign(
  {
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    rtk: 'DUMMY_REFRESH_TOKEN',
    rtkexp: Math.floor(Date.now() / 1000) - 1, // expired
    ssnexp: Math.floor(Date.now() / 1000) - 1, // expired
  },
  MOCK_AUTH_SECRET,
  { algorithm: 'HS256', expiresIn: '1h' }
)
const MOCK_JWT_INCORRECT_SECRET = jwt.sign(
  {
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    rtk: 'DUMMY_REFRESH_TOKEN',
    rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
    ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
  },
  'incorrect-secret',
  { algorithm: 'HS256', expiresIn: '1h' }
)
const MOCK_JWT_INVALID_ALGORITHM = jwt.sign(
  {
    sub: MOCK_SUBJECT,
    email: MOCK_EMAIL,
    rtk: 'DUMMY_REFRESH_TOKEN',
    rtkexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
    ssnexp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
  },
  null,
  { algorithm: 'none', expiresIn: '1h' }
)
jest.unstable_mockModule('oauth4webapi', () => {
  return {
    ...oauth2,
    discoveryRequest: jest.fn(async () => {
      return new Response(
        JSON.stringify({
          issuer: MOCK_ISSUER,
          authorization_endpoint: `${MOCK_ISSUER}/auth`,
          token_endpoint: `${MOCK_ISSUER}/token`,
          revocation_endpoint: `${MOCK_ISSUER}/revoke`,
          scopes_supported: ['openid', 'email', 'profile'],
        })
      )
    }),
    generateRandomState: jest.fn(() => MOCK_STATE),
    generateRandomNonce: jest.fn(() => MOCK_NONCE),
    authorizationCodeGrantRequest: jest.fn(async () => {
      return new Response(
        JSON.stringify({
          access_token: 'DUMMY_ACCESS_TOKEN',
          expires_in: 3599,
          refresh_token: 'DUUMMY_REFRESH_TOKEN',
          scope: 'https://www.googleapis.com/auth/userinfo.email openid',
          token_type: 'Bearer',
          id_token: MOCK_ID_TOKEN,
        })
      )
    }),
    refreshTokenGrantRequest: jest.fn(async () => {
      return new Response(
        JSON.stringify({
          access_token: 'DUMMY_ACCESS_TOKEN',
          expires_in: 3599,
          refresh_token: 'DUUMMY_REFRESH_TOKEN_RENEWED',
          scope: 'https://www.googleapis.com/auth/userinfo.email openid',
          token_type: 'Bearer',
          id_token: MOCK_ID_TOKEN,
        })
      )
    }),
    revocationRequest: jest.fn(async () => {
      return new Response(JSON.stringify({}))
    }),
  }
})

const { oidcAuthMiddleware, getAuth, processOAuthCallback, revokeSession } = await import('../src')

const app = new Hono()
app.get('/logout', async (c) => {
  await revokeSession(c)
  return c.text('OK')
})
app.get('/callback-custom', async (c) => {
  c.set('oidcClaimsHook', async (orig, claims, response) => ({
    name: (claims?.name as string) ?? orig?.name ?? '',
    sub: claims?.sub ?? orig?.sub ?? '',
    token: response.access_token,
  }))
  return processOAuthCallback(c)
})
app.use('/*', oidcAuthMiddleware())
app.all('/*', async (c) => {
  const auth = await getAuth(c)
  return c.text(`Hello ${auth?.email}! Refresh token: ${auth?.rtk}`)
})

beforeEach(() => {
  process.env.OIDC_ISSUER = MOCK_ISSUER
  process.env.OIDC_CLIENT_ID = MOCK_CLIENT_ID
  process.env.OIDC_CLIENT_SECRET = MOCK_CLIENT_SECRET
  process.env.OIDC_REDIRECT_URI = MOCK_REDIRECT_URI
  process.env.OIDC_AUTH_SECRET = MOCK_AUTH_SECRET
  process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES
  delete process.env.OIDC_SCOPES
  delete process.env.OIDC_COOKIE_PATH
  delete process.env.OIDC_COOKIE_NAME
})
describe('oidcAuthMiddleware()', () => {
  test('Should respond with 200 OK if session is active', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(200)
    expect(await res.text()).toBe(`Hello ${MOCK_EMAIL}! Refresh token: DUMMY_REFRESH_TOKEN`)
  })
  test('Should respond with 200 OK with renewed refresh token', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_TOKEN_EXPIRED_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(200)
    expect(await res.text()).toBe(
      `Hello ${MOCK_EMAIL}! Refresh token: DUUMMY_REFRESH_TOKEN_RENEWED`
    )
  })
  test('Should redirect to authorization endpoint if session is expired', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toMatch(/scope=openid(%20|\+)email(%20|\+)profile&/)
    expect(res.headers.get('location')).toMatch('access_type=offline&prompt=consent')
    expect(res.headers.get('set-cookie')).toMatch(`state=${MOCK_STATE}`)
    expect(res.headers.get('set-cookie')).toMatch(`nonce=${MOCK_NONCE}`)
    expect(res.headers.get('set-cookie')).toMatch('code_verifier=')
    expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F')
  })
  test('Should use custom scope, if defined', async () => {
    process.env.OIDC_SCOPES = 'openid email'
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toMatch(/scope=openid(%20|\+)email&/)
    expect(res.headers.get('location')).toMatch('access_type=offline&prompt=consent')
    expect(res.headers.get('set-cookie')).toMatch(`state=${MOCK_STATE}`)
    expect(res.headers.get('set-cookie')).toMatch(`nonce=${MOCK_NONCE}`)
    expect(res.headers.get('set-cookie')).toMatch('code_verifier=')
    expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F')
  })
  test('Custom scope is limited to supported scopes', async () => {
    process.env.OIDC_SCOPES = 'openid email salary'
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(500)
  })
  test('Should redirect to authorization endpoint if no session cookie is found', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
  })
  test('Should delete session and redirect to authorization endpoint if the key of the session JWT is icorrect', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_INCORRECT_SECRET}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
  })
  test('Should delete session and redirect to authorization endpoint if the algorithm of the session JWT is invalid', async () => {
    const req = new Request('http://localhost/', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_INVALID_ALGORITHM}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
  })
})
describe('processOAuthCallback()', () => {
  test('Should successfully process the OAuth2.0 callback and redirect to the continue URL', async () => {
    const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await app.request(req, {}, {})
    const { email, name, sub } = JSON.parse(
      atob(
        res.headers
          .get('set-cookie')
          ?.match(/oidc-auth=[^;]+/)?.[0]
          ?.split('.')[1] as string
      )
    )
    expect(sub).toBe(MOCK_SUBJECT)
    expect(email).toBe(MOCK_EMAIL)
    expect(name).toBeUndefined()
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('location')).toBe('http://localhost/1234')
  })
  test('Should respond with customized claims', async () => {
    const req = new Request(`${MOCK_REDIRECT_URI}-custom?code=1234&state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await app.request(req, {}, {})
    const { email, name, sub } = JSON.parse(
      atob(
        res.headers
          .get('set-cookie')
          ?.match(/oidc-auth=[^;]+/)?.[0]
          ?.split('.')[1] as string
      )
    )
    expect(sub).toBe(MOCK_SUBJECT)
    expect(email).toBeUndefined()
    expect(name).toBe(MOCK_NAME)
    const path = new URL(MOCK_REDIRECT_URI).pathname
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp('oidc-auth=[^;]+; Path=/; HttpOnly; Secure')
    )
    expect(res.headers.get('location')).toBe('http://localhost/1234')
  })
  test('Should restrict the auth cookie to a given path', async () => {
    const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication')
    process.env.OIDC_REDIRECT_URI = `http://localhost${MOCK_COOKIE_PATH}/callback`
    const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
    const path = new URL(process.env.OIDC_REDIRECT_URI).pathname
    const req = new Request(`${process.env.OIDC_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await parentApp.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)
    )
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`oidc-auth=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`)
    )
    expect(res.headers.get('location')).toBe('http://localhost/1234')
  })
  test('Should respond with custom cookie name', async () => {
    const MOCK_COOKIE_NAME = (process.env.OIDC_COOKIE_NAME = 'custom-auth-cookie')
    const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(302)
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`${MOCK_COOKIE_NAME}=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`)
    )
  })
  test('Should return an error if the state parameter does not match', async () => {
    const req = new Request(`${MOCK_REDIRECT_URI}?code=1234&state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=abcd; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(500)
  })
  test('Should return an error if the code parameter is missing', async () => {
    const req = new Request(`${MOCK_REDIRECT_URI}?state=${MOCK_STATE}`, {
      method: 'GET',
      headers: {
        cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
      },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(500)
  })
  test('Should return an error if received OAuth2.0 error', async () => {
    const req = new Request(
      `${MOCK_REDIRECT_URI}?error=invalid_grant&error_description=Bad+Request&state=1234`,
      {
        method: 'GET',
        headers: { cookie: 'state=1234; nonce=1234; code_verifier=1234' },
      }
    )
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(500)
  })
})
describe('RevokeSession()', () => {
  test('Should successfully revoke the session', async () => {
    const req = new Request('http://localhost/logout', {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
    })
    const res = await app.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(200)
    expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
  })
  test('Should revoke the session of the given path', async () => {
    const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication')
    const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
    const req = new Request(`http://localhost${MOCK_COOKIE_PATH}/logout`, {
      method: 'GET',
      headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
    })
    const res = await parentApp.request(req, {}, {})
    expect(res).not.toBeNull()
    expect(res.status).toBe(200)
    expect(res.headers.get('set-cookie')).toMatch(
      new RegExp(`oidc-auth=; Max-Age=0; Path=${MOCK_COOKIE_PATH}($|,)`)
    )
  })
})
