import type {
  JWTPayload,
  JWTClaimVerificationOptions,
  JWEHeaderParameters,
  JWSHeaderParameters,
} from '../types.d.ts'
import { JWTClaimValidationFailed, JWTExpired, JWTInvalid } from '../util/errors.ts'
import { decoder } from './buffer_utils.ts'
import epoch from './epoch.ts'
import secs from './secs.ts'
import isObject from './is_object.ts'

const normalizeTyp = (value: string) => value.toLowerCase().replace(/^application\//, '')

const checkAudiencePresence = (audPayload: unknown, audOption: unknown[]) => {
  if (typeof audPayload === 'string') {
    return audOption.includes(audPayload)
  }

  if (Array.isArray(audPayload)) {
    // Each principal intended to process the JWT MUST
    // identify itself with a value in the audience claim
    return audOption.some(Set.prototype.has.bind(new Set(audPayload)))
  }

  return false
}

export default (
  protectedHeader: JWEHeaderParameters | JWSHeaderParameters,
  encodedPayload: Uint8Array,
  options: JWTClaimVerificationOptions = {},
) => {
  const { typ } = options
  if (
    typ &&
    (typeof protectedHeader!.typ !== 'string' ||
      normalizeTyp(protectedHeader!.typ) !== normalizeTyp(typ))
  ) {
    throw new JWTClaimValidationFailed('unexpected "typ" JWT header value', 'typ', 'check_failed')
  }

  let payload!: { [propName: string]: unknown }
  try {
    payload = JSON.parse(decoder.decode(encodedPayload))
  } catch {
    //
  }

  if (!isObject(payload)) {
    throw new JWTInvalid('JWT Claims Set must be a top-level JSON object')
  }

  const { issuer } = options
  if (issuer && !(<unknown[]>(Array.isArray(issuer) ? issuer : [issuer])).includes(payload.iss!)) {
    throw new JWTClaimValidationFailed('unexpected "iss" claim value', 'iss', 'check_failed')
  }

  const { subject } = options
  if (subject && payload.sub !== subject) {
    throw new JWTClaimValidationFailed('unexpected "sub" claim value', 'sub', 'check_failed')
  }

  const { audience } = options
  if (
    audience &&
    !checkAudiencePresence(payload.aud, typeof audience === 'string' ? [audience] : audience)
  ) {
    throw new JWTClaimValidationFailed('unexpected "aud" claim value', 'aud', 'check_failed')
  }

  let tolerance: number
  switch (typeof options.clockTolerance) {
    case 'string':
      tolerance = secs(options.clockTolerance)
      break
    case 'number':
      tolerance = options.clockTolerance
      break
    case 'undefined':
      tolerance = 0
      break
    default:
      throw new TypeError('Invalid clockTolerance option type')
  }

  const { currentDate } = options
  const now = epoch(currentDate || new Date())

  if ((payload.iat !== undefined || options.maxTokenAge) && typeof payload.iat !== 'number') {
    throw new JWTClaimValidationFailed('"iat" claim must be a number', 'iat', 'invalid')
  }

  if (payload.nbf !== undefined) {
    if (typeof payload.nbf !== 'number') {
      throw new JWTClaimValidationFailed('"nbf" claim must be a number', 'nbf', 'invalid')
    }
    if (payload.nbf > now + tolerance) {
      throw new JWTClaimValidationFailed(
        '"nbf" claim timestamp check failed',
        'nbf',
        'check_failed',
      )
    }
  }

  if (payload.exp !== undefined) {
    if (typeof payload.exp !== 'number') {
      throw new JWTClaimValidationFailed('"exp" claim must be a number', 'exp', 'invalid')
    }
    if (payload.exp <= now - tolerance) {
      throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed')
    }
  }

  if (options.maxTokenAge) {
    const age = now - payload.iat!
    const max =
      typeof options.maxTokenAge === 'number' ? options.maxTokenAge : secs(options.maxTokenAge)

    if (age - tolerance > max) {
      throw new JWTExpired(
        '"iat" claim timestamp check failed (too far in the past)',
        'iat',
        'check_failed',
      )
    }

    if (age < 0 - tolerance) {
      throw new JWTClaimValidationFailed(
        '"iat" claim timestamp check failed (it should be in the past)',
        'iat',
        'check_failed',
      )
    }
  }

  return <JWTPayload>payload
}
