import { describe, expect, it } from 'vitest'
import { authorize, exchangeToken, getClient, registerClient } from './index.js'
import { deriveCodeChallengeS256 } from './pkce.js'
import {
  createTestDb,
  getOAuthCodeRecord,
  getOAuthTokenRecord,
  registerTestClient,
} from './test-fixture.js'

describe('auth/oauth-provider', () => {
  it('registers a client and reads it back by id', async () => {
    const db = await createTestDb()
    const registered = await registerClient(db, {
      id: 'plugin-backend-core',
      name: 'Plugin Backend Core',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read', 'plugin.write'],
      confidential: false,
    })

    expect(registered).toEqual({
      clientId: 'plugin-backend-core',
      clientSecret: undefined,
    })

    await expect(getClient(db, registered.clientId)).resolves.toMatchObject({
      id: 'plugin-backend-core',
      name: 'Plugin Backend Core',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read', 'plugin.write'],
      confidential: false,
    })
  })

  it('authorizes a client and persists a code bound to the PKCE challenge', async () => {
    const db = await createTestDb()
    const client = await registerTestClient(db)
    const verifier = 'pkce-verifier-authorize'
    const challenge = await deriveCodeChallengeS256(verifier)
    const clientDetails = await getClient(db, client.clientId)

    const authorized = await authorize(db, {
      clientId: client.clientId,
      redirectUri: clientDetails?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    expect(authorized.code).toBeTruthy()

    const codeRecord = await getOAuthCodeRecord(db, authorized.code)
    expect(codeRecord).toMatchObject({
      clientId: client.clientId,
      redirectUri: clientDetails?.redirectUris[0],
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
      usedAt: null,
    })
  })

  it('exchanges a valid verifier for a bearer access token', async () => {
    const db = await createTestDb()
    const client = await registerTestClient(db)
    const verifier = 'pkce-verifier-token'
    const challenge = await deriveCodeChallengeS256(verifier)
    const clientDetails = await getClient(db, client.clientId)
    const authorized = await authorize(db, {
      clientId: client.clientId,
      redirectUri: clientDetails?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read plugin.write',
    })

    const token = await exchangeToken(db, {
      code: authorized.code,
      codeVerifier: verifier,
      clientId: client.clientId,
    })

    expect(token).toMatchObject({
      tokenType: 'Bearer',
      expiresIn: 3600,
    })
    expect(token.accessToken.split('.')).toHaveLength(2)

    const tokenRecord = await getOAuthTokenRecord(db, token.accessToken)
    expect(tokenRecord).toMatchObject({
      clientId: client.clientId,
      scope: 'plugin.read plugin.write',
      tokenType: 'Bearer',
    })
    expect(tokenRecord?.accessTokenHash).not.toBe(token.accessToken)
  })

  it('rejects an exchange with the wrong PKCE verifier', async () => {
    const db = await createTestDb()
    const client = await registerTestClient(db)
    const verifier = 'pkce-verifier-right'
    const challenge = await deriveCodeChallengeS256(verifier)
    const clientDetails = await getClient(db, client.clientId)
    const authorized = await authorize(db, {
      clientId: client.clientId,
      redirectUri: clientDetails?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    await expect(
      exchangeToken(db, {
        code: authorized.code,
        codeVerifier: 'pkce-verifier-wrong',
        clientId: client.clientId,
      }),
    ).rejects.toMatchObject({
      name: 'InvalidOAuthCodeVerifierError',
      code: 'OAUTH_CODE_VERIFIER_INVALID',
    })
  })

  it('rejects a second exchange for the same code', async () => {
    const db = await createTestDb()
    const client = await registerTestClient(db)
    const verifier = 'pkce-verifier-single-use'
    const challenge = await deriveCodeChallengeS256(verifier)
    const clientDetails = await getClient(db, client.clientId)
    const authorized = await authorize(db, {
      clientId: client.clientId,
      redirectUri: clientDetails?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    await exchangeToken(db, {
      code: authorized.code,
      codeVerifier: verifier,
      clientId: client.clientId,
    })

    await expect(
      exchangeToken(db, {
        code: authorized.code,
        codeVerifier: verifier,
        clientId: client.clientId,
      }),
    ).rejects.toMatchObject({
      name: 'OAuthCodeAlreadyUsedError',
      code: 'OAUTH_CODE_ALREADY_USED',
    })
  })

  it('rejects the plain PKCE method', async () => {
    const db = await createTestDb()
    const client = await registerTestClient(db)
    const clientDetails = await getClient(db, client.clientId)

    await expect(
      authorize(db, {
        clientId: client.clientId,
        redirectUri: clientDetails?.redirectUris[0]!,
        codeChallenge: 'plain-challenge',
        codeChallengeMethod: 'plain' as 'S256',
        scope: 'plugin.read',
      }),
    ).rejects.toMatchObject({
      name: 'UnsupportedCodeChallengeMethodError',
      code: 'OAUTH_UNSUPPORTED_CODE_CHALLENGE_METHOD',
    })
  })

  it('returns a client secret once for a confidential client and stores only its hash', async () => {
    const db = await createTestDb()
    const registered = await registerClient(db, {
      id: 'confidential-client',
      name: 'Confidential Client',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read'],
      confidential: true,
    })

    expect(registered.clientId).toBe('confidential-client')
    expect(registered.clientSecret).toBeTruthy()

    await expect(getClient(db, registered.clientId)).resolves.toMatchObject({
      id: 'confidential-client',
      confidential: true,
    })
  })

  it('exchanges a code for a confidential client when the client secret is correct', async () => {
    const db = await createTestDb()
    const registered = await registerClient(db, {
      id: 'confidential-client',
      name: 'Confidential Client',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read'],
      confidential: true,
    })
    const verifier = 'pkce-verifier-confidential'
    const challenge = await deriveCodeChallengeS256(verifier)
    const client = await getClient(db, registered.clientId)
    const authorized = await authorize(db, {
      clientId: registered.clientId,
      redirectUri: client?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    const token = await exchangeToken(db, {
      code: authorized.code,
      codeVerifier: verifier,
      clientId: registered.clientId,
      clientSecret: registered.clientSecret,
    })

    expect(token.tokenType).toBe('Bearer')
  })

  it('rejects a confidential exchange when the client secret is missing', async () => {
    const db = await createTestDb()
    const registered = await registerClient(db, {
      id: 'confidential-client',
      name: 'Confidential Client',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read'],
      confidential: true,
    })
    const verifier = 'pkce-verifier-missing-secret'
    const challenge = await deriveCodeChallengeS256(verifier)
    const client = await getClient(db, registered.clientId)
    const authorized = await authorize(db, {
      clientId: registered.clientId,
      redirectUri: client?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    await expect(
      exchangeToken(db, {
        code: authorized.code,
        codeVerifier: verifier,
        clientId: registered.clientId,
      }),
    ).rejects.toMatchObject({
      name: 'MissingOAuthClientSecretError',
      code: 'OAUTH_CLIENT_SECRET_MISSING',
    })
  })

  it('rejects a confidential exchange when the client secret is wrong', async () => {
    const db = await createTestDb()
    const registered = await registerClient(db, {
      id: 'confidential-client',
      name: 'Confidential Client',
      redirectUris: ['https://plugin.example.com/oauth/callback'],
      scopes: ['plugin.read'],
      confidential: true,
    })
    const verifier = 'pkce-verifier-wrong-secret'
    const challenge = await deriveCodeChallengeS256(verifier)
    const client = await getClient(db, registered.clientId)
    const authorized = await authorize(db, {
      clientId: registered.clientId,
      redirectUri: client?.redirectUris[0]!,
      codeChallenge: challenge,
      codeChallengeMethod: 'S256',
      scope: 'plugin.read',
    })

    await expect(
      exchangeToken(db, {
        code: authorized.code,
        codeVerifier: verifier,
        clientId: registered.clientId,
        clientSecret: 'wrong-secret',
      }),
    ).rejects.toMatchObject({
      name: 'InvalidOAuthClientSecretError',
      code: 'OAUTH_CLIENT_SECRET_INVALID',
    })
  })
})
