import * as jose from "jose"
import * as o from "oauth4webapi"
import { InvalidCheck } from "../../errors.js"
import { decode, encode } from "../../jwt.js"

import type {
  CookiesOptions,
  InternalOptions,
  RequestInternal,
} from "../../types.js"
import type { Cookie } from "../cookie.js"

interface CheckPayload {
  value: string
}

/** Returns a signed cookie. */
export async function signCookie(
  type: keyof CookiesOptions,
  value: string,
  maxAge: number,
  options: InternalOptions<"oauth" | "oidc">,
  data?: any
): Promise<Cookie> {
  const { cookies, logger } = options

  logger.debug(`CREATE_${type.toUpperCase()}`, { value, maxAge })

  const expires = new Date()
  expires.setTime(expires.getTime() + maxAge * 1000)
  const token: any = { value }
  if (type === "state" && data) token.data = data
  return {
    name: cookies[type].name,
    value: await encode({ ...options.jwt, maxAge, token }),
    options: { ...cookies[type].options, expires },
  }
}

const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
export const pkce = {
  async create(options: InternalOptions<"oauth">) {
    const code_verifier = o.generateRandomCodeVerifier()
    const value = await o.calculatePKCECodeChallenge(code_verifier)
    const maxAge = PKCE_MAX_AGE
    const cookie = await signCookie(
      "pkceCodeVerifier",
      code_verifier,
      maxAge,
      options
    )
    return { cookie, value }
  },
  /**
   * Returns code_verifier if the provider is configured to use PKCE,
   * and clears the container cookie afterwards.
   * An error is thrown if the code_verifier is missing or invalid.
   * @see https://www.rfc-editor.org/rfc/rfc7636
   * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#pkce
   */
  async use(
    cookies: RequestInternal["cookies"],
    resCookies: Cookie[],
    options: InternalOptions<"oauth">
  ): Promise<string | undefined> {
    const { provider } = options

    if (!provider?.checks?.includes("pkce")) return

    const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name]

    if (!codeVerifier)
      throw new InvalidCheck("PKCE code_verifier cookie was missing.")

    const value = await decode<CheckPayload>({
      ...options.jwt,
      token: codeVerifier,
    })

    if (!value?.value)
      throw new InvalidCheck("PKCE code_verifier value could not be parsed.")

    // Clear the pkce code verifier cookie after use
    resCookies.push({
      name: options.cookies.pkceCodeVerifier.name,
      value: "",
      options: { ...options.cookies.pkceCodeVerifier.options, maxAge: 0 },
    })

    return value.value
  },
}

const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
export function decodeState(value: string):
  | {
      /** If defined, a redirect proxy is being used to support multiple OAuth apps with a single callback URL */
      origin?: string
      /** Random value for CSRF protection */
      random: string
    }
  | undefined {
  try {
    const decoder = new TextDecoder()
    return JSON.parse(decoder.decode(jose.base64url.decode(value)))
  } catch {}
}

export const state = {
  async create(options: InternalOptions<"oauth">, data?: object) {
    const { provider } = options
    if (!provider.checks.includes("state")) {
      if (data) {
        throw new InvalidCheck(
          "State data was provided but the provider is not configured to use state."
        )
      }
      return
    }

    const encodedState = jose.base64url.encode(
      JSON.stringify({ ...data, random: o.generateRandomState() })
    )

    const maxAge = STATE_MAX_AGE
    const cookie = await signCookie(
      "state",
      encodedState,
      maxAge,
      options,
      data
    )
    return { cookie, value: encodedState }
  },
  /**
   * Returns state if the provider is configured to use state,
   * and clears the container cookie afterwards.
   * An error is thrown if the state is missing or invalid.
   * @see https://www.rfc-editor.org/rfc/rfc6749#section-10.12
   * @see https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1
   */
  async use(
    cookies: RequestInternal["cookies"],
    resCookies: Cookie[],
    options: InternalOptions<"oauth">,
    paramRandom?: string
  ): Promise<string | undefined> {
    const { provider } = options
    if (!provider.checks.includes("state")) return

    const state = cookies?.[options.cookies.state.name]

    if (!state) throw new InvalidCheck("State cookie was missing.")

    // IDEA: Let the user do something with the returned state
    const encodedState = await decode<CheckPayload>({
      ...options.jwt,
      token: state,
    })

    if (!encodedState?.value)
      throw new InvalidCheck("State (cookie) value could not be parsed.")

    const decodedState = decodeState(encodedState.value)

    if (!decodedState)
      throw new InvalidCheck("State (encoded) value could not be parsed.")

    if (decodedState.random !== paramRandom)
      throw new InvalidCheck(
        `Random state values did not match. Expected: ${decodedState.random}. Got: ${paramRandom}`
      )

    // Clear the state cookie after use
    resCookies.push({
      name: options.cookies.state.name,
      value: "",
      options: { ...options.cookies.state.options, maxAge: 0 },
    })

    return encodedState.value
  },
}

const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
export const nonce = {
  async create(options: InternalOptions<"oidc">) {
    if (!options.provider.checks.includes("nonce")) return
    const value = o.generateRandomNonce()
    const maxAge = NONCE_MAX_AGE
    const cookie = await signCookie("nonce", value, maxAge, options)
    return { cookie, value }
  },
  /**
   * Returns nonce if the provider is configured to use nonce,
   * and clears the container cookie afterwards.
   * An error is thrown if the nonce is missing or invalid.
   * @see https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes
   * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#nonce
   */
  async use(
    cookies: RequestInternal["cookies"],
    resCookies: Cookie[],
    options: InternalOptions<"oidc">
  ): Promise<string | undefined> {
    const { provider } = options

    if (!provider?.checks?.includes("nonce")) return

    const nonce = cookies?.[options.cookies.nonce.name]
    if (!nonce) throw new InvalidCheck("Nonce cookie was missing.")

    const value = await decode<CheckPayload>({ ...options.jwt, token: nonce })

    if (!value?.value)
      throw new InvalidCheck("Nonce value could not be parsed.")

    // Clear the nonce cookie after use
    resCookies.push({
      name: options.cookies.nonce.name,
      value: "",
      options: { ...options.cookies.nonce.options, maxAge: 0 },
    })

    return value.value
  },
}
