import { AccountNotLinkedError } from "../errors"
import { fromDate } from "./utils"

import type { InternalOptions } from "../types"
import type { AdapterSession, AdapterUser } from "../../adapters"
import type { JWT } from "../../jwt"
import type { Account, User } from "../.."
import type { SessionToken } from "./cookie"
import { OAuthConfig } from "src/providers"

/**
 * This function handles the complex flow of signing users in, and either creating,
 * linking (or not linking) accounts depending on if the user is currently logged
 * in, if they have account already and the authentication mechanism they are using.
 *
 * It prevents insecure behaviour, such as linking OAuth accounts unless a user is
 * signed in and authenticated with an existing valid account.
 *
 * All verification (e.g. OAuth flows or email address verificaiton flows) are
 * done prior to this handler being called to avoid additonal complexity in this
 * handler.
 */
export default async function callbackHandler(params: {
  sessionToken?: SessionToken
  profile: User | AdapterUser | { email: string }
  account: Account | null
  options: InternalOptions
}) {
  const { sessionToken, profile: _profile, account, options } = params
  // Input validation
  if (!account?.providerAccountId || !account.type)
    throw new Error("Missing or invalid provider account")
  if (!["email", "oauth"].includes(account.type))
    throw new Error("Provider not supported")

  const {
    adapter,
    jwt,
    events,
    session: { strategy: sessionStrategy, generateSessionToken },
  } = options

  // If no adapter is configured then we don't have a database and cannot
  // persist data; in this mode we just return a dummy session object.
  if (!adapter) {
    return { user: _profile as User, account }
  }

  const profile = _profile as AdapterUser

  const {
    createUser,
    updateUser,
    getUser,
    getUserByAccount,
    getUserByEmail,
    linkAccount,
    createSession,
    getSessionAndUser,
    deleteSession,
  } = adapter

  let session: AdapterSession | JWT | null = null
  let user: AdapterUser | null = null
  let isNewUser = false

  const useJwtSession = sessionStrategy === "jwt"

  if (sessionToken) {
    if (useJwtSession) {
      try {
        session = await jwt.decode({ ...jwt, token: sessionToken })
        if (session && "sub" in session && session.sub) {
          user = await getUser(session.sub)
        }
      } catch {
        // If session can't be verified, treat as no session
      }
    } else {
      const userAndSession = await getSessionAndUser(sessionToken)
      if (userAndSession) {
        session = userAndSession.session
        user = userAndSession.user
      }
    }
  }

  if (account.type === "email") {
    // If signing in with an email, check if an account with the same email address exists already
    const userByEmail = await getUserByEmail(profile.email)
    if (userByEmail) {
      // If they are not already signed in as the same user, this flow will
      // sign them out of the current session and sign them in as the new user
      if (user?.id !== userByEmail.id && !useJwtSession && sessionToken) {
        // Delete existing session if they are currently signed in as another user.
        // This will switch user accounts for the session in cases where the user was
        // already logged in with a different account.
        await deleteSession(sessionToken)
      }

      // Update emailVerified property on the user object
      user = await updateUser({ id: userByEmail.id, emailVerified: new Date() })
      await events.updateUser?.({ user })
    } else {
      const { id: _, ...newUser } = { ...profile, emailVerified: new Date() }
      // Create user account if there isn't one for the email address already
      // @ts-expect-error see adapters.ts' FutureAdapter["createUser"]
      user = await createUser(newUser)
      await events.createUser?.({ user })
      isNewUser = true
    }

    // Create new session
    session = useJwtSession
      ? {}
      : await createSession({
          sessionToken: await generateSessionToken(),
          userId: user.id,
          expires: fromDate(options.session.maxAge),
        })

    return { session, user, isNewUser }
  } else if (account.type === "oauth") {
    // If signing in with OAuth account, check to see if the account exists already
    const userByAccount = await getUserByAccount({
      providerAccountId: account.providerAccountId,
      provider: account.provider,
    })
    if (userByAccount) {
      if (user) {
        // If the user is already signed in with this account, we don't need to do anything
        if (userByAccount.id === user.id) {
          return { session, user, isNewUser }
        }
        // If the user is currently signed in, but the new account they are signing in
        // with is already associated with another user, then we cannot link them
        // and need to return an error.
        throw new AccountNotLinkedError(
          "The account is already associated with another user"
        )
      }
      // If there is no active session, but the account being signed in with is already
      // associated with a valid user then create session to sign the user in.
      session = useJwtSession
        ? {}
        : await createSession({
            sessionToken: await generateSessionToken(),
            userId: userByAccount.id,
            expires: fromDate(options.session.maxAge),
          })

      return { session, user: userByAccount, isNewUser }
    } else {
      if (user) {
        // If the user is already signed in and the OAuth account isn't already associated
        // with another user account then we can go ahead and link the accounts safely.
        // @ts-expect-error see adapters.ts' FutureAdapter["linkAccount"]
        await linkAccount({ ...account, userId: user.id })
        await events.linkAccount?.({ user, account, profile })

        // As they are already signed in, we don't need to do anything after linking them
        return { session, user, isNewUser }
      }

      // If the user is not signed in and it looks like a new OAuth account then we
      // check there also isn't an user account already associated with the same
      // email address as the one in the OAuth profile.
      //
      // This step is often overlooked in OAuth implementations, but covers the following cases:
      //
      // 1. It makes it harder for someone to accidentally create two accounts.
      //    e.g. by signin in with email, then again with an oauth account connected to the same email.
      // 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
      //    e.g. by creating an oauth account then changing the email address associated with it.
      //
      // It's quite common for services to automatically link accounts in this case, but it's
      // better practice to require the user to sign in *then* link accounts to be sure
      // someone is not exploiting a problem with a third party OAuth service.
      //
      // OAuth providers should require email address verification to prevent this, but in
      // practice that is not always the case; this helps protect against that.
      const userByEmail = profile.email
        ? await getUserByEmail(profile.email)
        : null
      if (userByEmail) {
        const provider = options.provider as OAuthConfig<any>
        if (provider?.allowDangerousEmailAccountLinking) {
          // If you trust the oauth provider to correctly verify email addresses, you can opt-in to
          // account linking even when the user is not signed-in.
          user = userByEmail
        } else {
          // We end up here when we don't have an account with the same [provider].id *BUT*
          // we do already have an account with the same email address as the one in the
          // OAuth profile the user has just tried to sign in with.
          //
          // We don't want to have two accounts with the same email address, and we don't
          // want to link them in case it's not safe to do so, so instead we prompt the user
          // to sign in via email to verify their identity and then link the accounts.
          throw new AccountNotLinkedError(
            "Another account already exists with the same e-mail address"
          )
        }
      } else {
        // If the current user is not logged in and the profile isn't linked to any user
        // accounts (by email or provider account id)...
        //
        // If no account matching the same [provider].id or .email exists, we can
        // create a new account for the user, link it to the OAuth acccount and
        // create a new session for them so they are signed in with it.
        const { id: _, ...newUser } = { ...profile, emailVerified: null }
        // @ts-expect-error see adapters.ts' FutureAdapter["createUser"]
        user = await createUser(newUser)
      }
      await events.createUser?.({ user })

      // @ts-expect-error see adapters.ts' FutureAdapter["linkAccount"]
      await linkAccount({ ...account, userId: user.id })
      await events.linkAccount?.({ user, account, profile })

      session = useJwtSession
        ? {}
        : await createSession({
            sessionToken: await generateSessionToken(),
            userId: user.id,
            expires: fromDate(options.session.maxAge),
          })

      return { session, user, isNewUser: true }
    }
  }

  throw new Error("Unsupported account type")
}
