import type { WebAuthnProviderType } from "../../providers/webauthn.js"
import type {
  Account,
  Authenticator,
  Awaited,
  InternalOptions,
  RequestInternal,
  ResponseInternal,
  User,
} from "../../types.js"
import type { Cookie } from "./cookie.js"
import {
  AdapterError,
  AuthError,
  InvalidProvider,
  MissingAdapter,
  WebAuthnVerificationError,
} from "../../errors.js"
import { webauthnChallenge } from "../actions/callback/oauth/checks.js"
import type {
  AuthenticationResponseJSON,
  PublicKeyCredentialCreationOptionsJSON,
  PublicKeyCredentialRequestOptionsJSON,
  RegistrationResponseJSON,
} from "@simplewebauthn/server/script/deps"
import type {
  Adapter,
  AdapterAccount,
  AdapterAuthenticator,
} from "../../adapters.js"
import type { GetUserInfo } from "../../providers/webauthn.js"
import { randomString } from "./web.js"
import type {
  VerifiedAuthenticationResponse,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server"

export type WebAuthnRegister = "register"
export type WebAuthnAuthenticate = "authenticate"
export type WebAuthnAction = WebAuthnRegister | WebAuthnAuthenticate

type InternalOptionsWebAuthn = InternalOptions<WebAuthnProviderType> & {
  adapter: Required<Adapter>
}
export type WebAuthnOptionsResponseBody =
  | {
      action: WebAuthnAuthenticate
      options: PublicKeyCredentialRequestOptionsJSON
    }
  | {
      action: WebAuthnRegister
      options: PublicKeyCredentialCreationOptionsJSON
    }
type WebAuthnOptionsResponse = ResponseInternal & {
  body: WebAuthnOptionsResponseBody
}

export type CredentialDeviceType = "singleDevice" | "multiDevice"
interface InternalAuthenticator {
  providerAccountId: string
  credentialID: Uint8Array
  credentialPublicKey: Uint8Array
  counter: number
  credentialDeviceType: CredentialDeviceType
  credentialBackedUp: boolean
  transports?: AuthenticatorTransport[]
}

type RGetUserInfo = Awaited<ReturnType<GetUserInfo>>

/**
 * Infers the WebAuthn options based on the provided parameters.
 *
 * @param action - The WebAuthn action to perform (optional).
 * @param loggedInUser - The logged-in user (optional).
 * @param userInfoResponse - The response containing user information (optional).
 *
 * @returns The WebAuthn action to perform, or null if no inference could be made.
 */
export function inferWebAuthnOptions(
  action: WebAuthnAction | undefined,
  loggedIn: boolean,
  userInfoResponse: RGetUserInfo
): WebAuthnAction | null {
  const { user, exists = false } = userInfoResponse ?? {}

  switch (action) {
    case "authenticate": {
      /**
       * Always allow explicit authentication requests.
       */
      return "authenticate"
    }
    case "register": {
      /**
       * Registration is only allowed if:
       * - The user is logged in, meaning the user wants to register a new authenticator.
       * - The user is not logged in and provided user info that does NOT exist, meaning the user wants to register a new account.
       */
      if (user && loggedIn === exists) return "register"
      break
    }
    case undefined: {
      /**
       * When no explicit action is provided, we try to infer it based on the user info provided. These are the possible cases:
       * - Logged in users must always send an explit action, so we bail out in this case.
       * - Otherwise, if no user info is provided, the desired action is authentication without pre-defined authenticators.
       * - Otherwise, if the user info provided is of an existing user, the desired action is authentication with their pre-defined authenticators.
       * - Finally, if the user info provided is of a non-existing user, the desired action is registration.
       */
      if (!loggedIn) {
        if (user) {
          if (exists) {
            return "authenticate"
          } else {
            return "register"
          }
        } else {
          return "authenticate"
        }
      }
      break
    }
  }

  // No decision could be made
  return null
}

/**
 * Retrieves the registration response for WebAuthn options request.
 *
 * @param options - The internal options for WebAuthn.
 * @param request - The request object.
 * @param user - The user information.
 * @param resCookies - Optional cookies to be included in the response.
 * @returns A promise that resolves to the WebAuthnOptionsResponse.
 */
export async function getRegistrationResponse(
  options: InternalOptionsWebAuthn,
  request: RequestInternal,
  user: User & { email: string },
  resCookies?: Cookie[]
): Promise<WebAuthnOptionsResponse> {
  // Get registration options
  const regOptions = await getRegistrationOptions(options, request, user)
  // Get signed cookie
  const { cookie } = await webauthnChallenge.create(
    options,
    regOptions.challenge,
    user
  )

  return {
    status: 200,
    cookies: [...(resCookies ?? []), cookie],
    body: {
      action: "register" as const,
      options: regOptions,
    },
    headers: {
      "Content-Type": "application/json",
    },
  }
}

/**
 * Retrieves the authentication response for WebAuthn options request.
 *
 * @param options - The internal options for WebAuthn.
 * @param request - The request object.
 * @param user - Optional user information.
 * @param resCookies - Optional array of cookies to be included in the response.
 * @returns A promise that resolves to a WebAuthnOptionsResponse object.
 */
export async function getAuthenticationResponse(
  options: InternalOptionsWebAuthn,
  request: RequestInternal,
  user?: User,
  resCookies?: Cookie[]
): Promise<WebAuthnOptionsResponse> {
  // Get authentication options
  const authOptions = await getAuthenticationOptions(options, request, user)
  // Get signed cookie
  const { cookie } = await webauthnChallenge.create(
    options,
    authOptions.challenge
  )

  return {
    status: 200,
    cookies: [...(resCookies ?? []), cookie],
    body: {
      action: "authenticate" as const,
      options: authOptions,
    },
    headers: {
      "Content-Type": "application/json",
    },
  }
}

export async function verifyAuthenticate(
  options: InternalOptionsWebAuthn,
  request: RequestInternal,
  resCookies: Cookie[]
): Promise<{ account: AdapterAccount; user: User }> {
  const { adapter, provider } = options

  // Get WebAuthn response from request body
  const data =
    request.body && typeof request.body.data === "string"
      ? (JSON.parse(request.body.data) as unknown)
      : undefined
  if (
    !data ||
    typeof data !== "object" ||
    !("id" in data) ||
    typeof data.id !== "string"
  ) {
    throw new AuthError("Invalid WebAuthn Authentication response.")
  }

  // Reset the ID so we smooth out implementation differences
  const credentialID = toBase64(fromBase64(data.id))

  // Get authenticator from database
  const authenticator = await adapter.getAuthenticator(credentialID)
  if (!authenticator) {
    throw new AuthError(
      `WebAuthn authenticator not found in database: ${JSON.stringify({
        credentialID,
      })}`
    )
  }

  // Get challenge from request cookies
  const { challenge: expectedChallenge } = await webauthnChallenge.use(
    options,
    request.cookies,
    resCookies
  )

  // Verify the response
  let verification: VerifiedAuthenticationResponse
  try {
    const relayingParty = provider.getRelayingParty(options, request)
    verification = await provider.simpleWebAuthn.verifyAuthenticationResponse({
      ...provider.verifyAuthenticationOptions,
      expectedChallenge,
      response: data as AuthenticationResponseJSON,
      authenticator: fromAdapterAuthenticator(authenticator),
      expectedOrigin: relayingParty.origin,
      expectedRPID: relayingParty.id,
    })
  } catch (e: any) {
    throw new WebAuthnVerificationError(e)
  }

  const { verified, authenticationInfo } = verification

  // Make sure the response was verified
  if (!verified) {
    throw new WebAuthnVerificationError(
      "WebAuthn authentication response could not be verified."
    )
  }

  // Update authenticator counter
  try {
    const { newCounter } = authenticationInfo
    await adapter.updateAuthenticatorCounter(
      authenticator.credentialID,
      newCounter
    )
  } catch (e: any) {
    throw new AdapterError(
      `Failed to update authenticator counter. This may cause future authentication attempts to fail. ${JSON.stringify(
        {
          credentialID,
          oldCounter: authenticator.counter,
          newCounter: authenticationInfo.newCounter,
        }
      )}`,
      e
    )
  }

  // Get the account and user
  const account = await adapter.getAccount(
    authenticator.providerAccountId,
    provider.id
  )
  if (!account) {
    throw new AuthError(
      `WebAuthn account not found in database: ${JSON.stringify({
        credentialID,
        providerAccountId: authenticator.providerAccountId,
      })}`
    )
  }

  const user = await adapter.getUser(account.userId)
  if (!user) {
    throw new AuthError(
      `WebAuthn user not found in database: ${JSON.stringify({
        credentialID,
        providerAccountId: authenticator.providerAccountId,
        userID: account.userId,
      })}`
    )
  }

  return {
    account,
    user,
  }
}

export async function verifyRegister(
  options: InternalOptions<WebAuthnProviderType>,
  request: RequestInternal,
  resCookies: Cookie[]
): Promise<{ account: Account; user: User; authenticator: Authenticator }> {
  const { provider } = options

  // Get WebAuthn response from request body
  const data =
    request.body && typeof request.body.data === "string"
      ? (JSON.parse(request.body.data) as unknown)
      : undefined
  if (
    !data ||
    typeof data !== "object" ||
    !("id" in data) ||
    typeof data.id !== "string"
  ) {
    throw new AuthError("Invalid WebAuthn Registration response.")
  }

  // Get challenge from request cookies
  const { challenge: expectedChallenge, registerData: user } =
    await webauthnChallenge.use(options, request.cookies, resCookies)
  if (!user) {
    throw new AuthError(
      "Missing user registration data in WebAuthn challenge cookie."
    )
  }

  // Verify the response
  let verification: VerifiedRegistrationResponse
  try {
    const relayingParty = provider.getRelayingParty(options, request)
    verification = await provider.simpleWebAuthn.verifyRegistrationResponse({
      ...provider.verifyRegistrationOptions,
      expectedChallenge,
      response: data as RegistrationResponseJSON,
      expectedOrigin: relayingParty.origin,
      expectedRPID: relayingParty.id,
    })
  } catch (e: any) {
    throw new WebAuthnVerificationError(e)
  }

  // Make sure the response was verified
  if (!verification.verified || !verification.registrationInfo) {
    throw new WebAuthnVerificationError(
      "WebAuthn registration response could not be verified."
    )
  }

  // Build a new account
  const account = {
    providerAccountId: toBase64(verification.registrationInfo.credentialID),
    provider: options.provider.id,
    type: provider.type,
  }

  // Build a new authenticator
  const authenticator = {
    providerAccountId: account.providerAccountId,
    counter: verification.registrationInfo.counter,
    credentialID: toBase64(verification.registrationInfo.credentialID),
    credentialPublicKey: toBase64(
      verification.registrationInfo.credentialPublicKey
    ),
    credentialBackedUp: verification.registrationInfo.credentialBackedUp,
    credentialDeviceType: verification.registrationInfo.credentialDeviceType,
    transports: transportsToString(
      (data as RegistrationResponseJSON).response
        .transports as AuthenticatorTransport[]
    ),
  }

  // Return created stuff
  return {
    user,
    account,
    authenticator,
  }
}

/**
 * Generates WebAuthn authentication options.
 *
 * @param options - The internal options for WebAuthn.
 * @param request - The request object.
 * @param user - Optional user information.
 * @returns The authentication options.
 */
async function getAuthenticationOptions(
  options: InternalOptionsWebAuthn,
  request: RequestInternal,
  user?: User
) {
  const { provider, adapter } = options

  // Get the user's authenticators.
  const authenticators =
    user && user["id"]
      ? await adapter.listAuthenticatorsByUserId(user.id)
      : null

  const relayingParty = provider.getRelayingParty(options, request)

  // Return the authentication options.
  return await provider.simpleWebAuthn.generateAuthenticationOptions({
    ...provider.authenticationOptions,
    rpID: relayingParty.id,
    allowCredentials: authenticators?.map((a) => ({
      id: fromBase64(a.credentialID),
      type: "public-key",
      transports: stringToTransports(a.transports),
    })),
  })
}

/**
 * Generates WebAuthn registration options.
 *
 * @param options - The internal options for WebAuthn.
 * @param request - The request object.
 * @param user - The user information.
 * @returns The registration options.
 */
async function getRegistrationOptions(
  options: InternalOptionsWebAuthn,
  request: RequestInternal,
  user: User & { email: string }
) {
  const { provider, adapter } = options

  // Get the user's authenticators.
  const authenticators = user["id"]
    ? await adapter.listAuthenticatorsByUserId(user.id)
    : null

  // Generate a random user ID for the credential.
  // We can do this because we don't use this user ID to link the
  // credential to the user. Instead, we store actual userID in the
  // Authenticator object and fetch it via it's credential ID.
  const userID = randomString(32)

  const relayingParty = provider.getRelayingParty(options, request)

  // Return the registration options.
  return await provider.simpleWebAuthn.generateRegistrationOptions({
    ...provider.registrationOptions,
    userID,
    userName: user.email,
    userDisplayName: user.name ?? undefined,
    rpID: relayingParty.id,
    rpName: relayingParty.name,
    excludeCredentials: authenticators?.map((a) => ({
      id: fromBase64(a.credentialID),
      type: "public-key",
      transports: stringToTransports(a.transports),
    })),
  })
}

export function assertInternalOptionsWebAuthn(
  options: InternalOptions
): InternalOptionsWebAuthn {
  const { provider, adapter } = options

  // Adapter is required for WebAuthn
  if (!adapter)
    throw new MissingAdapter("An adapter is required for the WebAuthn provider")
  // Provider must be WebAuthn
  if (!provider || provider.type !== "webauthn") {
    throw new InvalidProvider("Provider must be WebAuthn")
  }
  // Narrow the options type for typed usage later
  return { ...options, provider, adapter }
}

function fromAdapterAuthenticator(
  authenticator: AdapterAuthenticator
): InternalAuthenticator {
  return {
    ...authenticator,
    credentialDeviceType:
      authenticator.credentialDeviceType as InternalAuthenticator["credentialDeviceType"],
    transports: stringToTransports(authenticator.transports),
    credentialID: fromBase64(authenticator.credentialID),
    credentialPublicKey: fromBase64(authenticator.credentialPublicKey),
  }
}

export function fromBase64(base64: string): Uint8Array {
  return new Uint8Array(Buffer.from(base64, "base64"))
}

export function toBase64(bytes: Uint8Array): string {
  return Buffer.from(bytes).toString("base64")
}

export function transportsToString(
  transports: InternalAuthenticator["transports"]
) {
  return transports?.join(",")
}

export function stringToTransports(
  tstring: string | undefined
): InternalAuthenticator["transports"] {
  return tstring
    ? (tstring.split(",") as InternalAuthenticator["transports"])
    : undefined
}
