import {
  CallbackRouteError,
  OAuthCallbackError,
  Verification,
} from "../../errors.js"
import { handleLogin } from "../callback-handler.js"
import { handleOAuth } from "../oauth/callback.js"
import { handleState } from "../oauth/handle-state.js"
import { createHash } from "../web.js"
import { handleAuthorized } from "./shared.js"

import type { AdapterSession } from "../../adapters.js"
import type {
  Account,
  InternalOptions,
  RequestInternal,
  ResponseInternal,
} from "../../types.js"
import type { Cookie, SessionStore } from "../cookie.js"

/** Handle callbacks from login services */
export async function callback(params: {
  options: InternalOptions
  query: RequestInternal["query"]
  method: Required<RequestInternal>["method"]
  body: RequestInternal["body"]
  headers: RequestInternal["headers"]
  cookies: RequestInternal["cookies"]
  sessionStore: SessionStore
}): Promise<ResponseInternal> {
  const { options, query, body, method, headers, sessionStore } = params
  const {
    provider,
    adapter,
    url,
    callbackUrl,
    pages,
    jwt,
    events,
    callbacks,
    session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
    logger,
  } = options

  const cookies: Cookie[] = []

  const useJwtSession = sessionStrategy === "jwt"

  try {
    if (provider.type === "oauth" || provider.type === "oidc") {
      const { proxyRedirect, randomState } = handleState(
        query,
        provider,
        options.isOnRedirectProxy
      )

      if (proxyRedirect) {
        logger.debug("proxy redirect", { proxyRedirect, randomState })
        return { redirect: proxyRedirect }
      }

      const authorizationResult = await handleOAuth(
        query,
        params.cookies,
        options,
        randomState
      )

      if (authorizationResult.cookies.length) {
        cookies.push(...authorizationResult.cookies)
      }

      logger.debug("authorization result", authorizationResult)

      const {
        user: userFromProvider,
        account,
        profile: OAuthProfile,
      } = authorizationResult

      // If we don't have a profile object then either something went wrong
      // or the user cancelled signing in. We don't know which, so we just
      // direct the user to the signin page for now. We could do something
      // else in future.
      // TODO: Handle user cancelling signin
      if (!userFromProvider || !account || !OAuthProfile) {
        return { redirect: `${url}/signin`, cookies }
      }

      // Check if user is allowed to sign in
      // Attempt to get Profile from OAuth provider details before invoking
      // signIn callback - but if no user object is returned, that is fine
      // (that just means it's a new user signing in for the first time).
      let userByAccountOrFromProvider
      if (adapter) {
        const { getUserByAccount } = adapter
        const userByAccount = await getUserByAccount({
          providerAccountId: account.providerAccountId,
          provider: provider.id,
        })

        if (userByAccount) userByAccountOrFromProvider = userByAccount
      }

      const unauthorizedOrError = await handleAuthorized(
        {
          user: userByAccountOrFromProvider,
          account,
          profile: OAuthProfile,
        },
        options
      )

      if (unauthorizedOrError) return { ...unauthorizedOrError, cookies }

      // Sign user in
      const { user, session, isNewUser } = await handleLogin(
        sessionStore.value,
        userFromProvider,
        account,
        options
      )

      if (useJwtSession) {
        const defaultToken = {
          name: user.name,
          email: user.email,
          picture: user.image,
          sub: user.id?.toString(),
        }
        const token = await callbacks.jwt({
          token: defaultToken,
          user,
          account,
          profile: OAuthProfile,
          isNewUser,
          trigger: isNewUser ? "signUp" : "signIn",
        })

        // Clear cookies if token is null
        if (token === null) {
          cookies.push(...sessionStore.clean())
        } else {
          // Encode token
          const newToken = await jwt.encode({ ...jwt, token })

          // Set cookie expiry date
          const cookieExpires = new Date()
          cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)

          const sessionCookies = sessionStore.chunk(newToken, {
            expires: cookieExpires,
          })
          cookies.push(...sessionCookies)
        }
      } else {
        // Save Session Token in cookie
        cookies.push({
          name: options.cookies.sessionToken.name,
          value: (session as AdapterSession).sessionToken,
          options: {
            ...options.cookies.sessionToken.options,
            expires: (session as AdapterSession).expires,
          },
        })
      }

      await events.signIn?.({ user, account, profile: OAuthProfile, isNewUser })

      // Handle first logins on new accounts
      // e.g. option to send users to a new account landing page on initial login
      // Note that the callback URL is preserved, so the journey can still be resumed
      if (isNewUser && pages.newUser) {
        return {
          redirect: `${pages.newUser}${
            pages.newUser.includes("?") ? "&" : "?"
          }${new URLSearchParams({ callbackUrl })}`,
          cookies,
        }
      }

      return { redirect: callbackUrl, cookies }
    } else if (provider.type === "email") {
      const token = query?.token as string | undefined
      const identifier = query?.email as string | undefined

      if (!token || !identifier) {
        const e = new TypeError(
          "Missing token or email. The sign-in URL was manually opened without token/identifier or the link was not sent correctly in the email.",
          { cause: { hasToken: !!token, hasEmail: !!identifier } }
        )
        e.name = "Configuration"
        throw e
      }

      const secret = provider.secret ?? options.secret
      // @ts-expect-error -- Verified in `assertConfig`.
      const invite = await adapter.useVerificationToken({
        identifier,
        token: await createHash(`${token}${secret}`),
      })

      const hasInvite = !!invite
      const expired = invite ? invite.expires.valueOf() < Date.now() : undefined
      const invalidInvite = !hasInvite || expired
      if (invalidInvite) throw new Verification({ hasInvite, expired })

      const user = (await adapter!.getUserByEmail(identifier)) ?? {
        id: identifier,
        email: identifier,
        emailVerified: null,
      }

      const account: Account = {
        providerAccountId: user.email,
        userId: user.id,
        type: "email" as const,
        provider: provider.id,
      }

      // Check if user is allowed to sign in
      const unauthorizedOrError = await handleAuthorized(
        { user, account },
        options
      )

      if (unauthorizedOrError) return { ...unauthorizedOrError, cookies }

      // Sign user in
      const {
        user: loggedInUser,
        session,
        isNewUser,
      } = await handleLogin(sessionStore.value, user, account, options)

      if (useJwtSession) {
        const defaultToken = {
          name: loggedInUser.name,
          email: loggedInUser.email,
          picture: loggedInUser.image,
          sub: loggedInUser.id?.toString(),
        }
        const token = await callbacks.jwt({
          token: defaultToken,
          user: loggedInUser,
          account,
          isNewUser,
          trigger: isNewUser ? "signUp" : "signIn",
        })

        // Clear cookies if token is null
        if (token === null) {
          cookies.push(...sessionStore.clean())
        } else {
          // Encode token
          const newToken = await jwt.encode({ ...jwt, token })

          // Set cookie expiry date
          const cookieExpires = new Date()
          cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)

          const sessionCookies = sessionStore.chunk(newToken, {
            expires: cookieExpires,
          })
          cookies.push(...sessionCookies)
        }
      } else {
        // Save Session Token in cookie
        cookies.push({
          name: options.cookies.sessionToken.name,
          value: (session as AdapterSession).sessionToken,
          options: {
            ...options.cookies.sessionToken.options,
            expires: (session as AdapterSession).expires,
          },
        })
      }

      await events.signIn?.({ user: loggedInUser, account, isNewUser })

      // Handle first logins on new accounts
      // e.g. option to send users to a new account landing page on initial login
      // Note that the callback URL is preserved, so the journey can still be resumed
      if (isNewUser && pages.newUser) {
        return {
          redirect: `${pages.newUser}${
            pages.newUser.includes("?") ? "&" : "?"
          }${new URLSearchParams({ callbackUrl })}`,
          cookies,
        }
      }

      // Callback URL is already verified at this point, so safe to use if specified
      return { redirect: callbackUrl, cookies }
    } else if (provider.type === "credentials" && method === "POST") {
      const credentials = body ?? {}

      // TODO: Forward the original request as is, instead of reconstructing it
      Object.entries(query ?? {}).forEach(([k, v]) =>
        url.searchParams.set(k, v)
      )
      const user = await provider.authorize(
        credentials,
        // prettier-ignore
        new Request(url, { headers, method, body: JSON.stringify(body) })
      )
      if (!user) {
        return {
          status: 401,
          redirect: `${url}/error?${new URLSearchParams({
            error: "CredentialsSignin",
            provider: provider.id,
          })}`,
          cookies,
        }
      }

      /** @type {import("src").Account} */
      const account = {
        providerAccountId: user.id,
        type: "credentials",
        provider: provider.id,
      }

      const unauthorizedOrError = await handleAuthorized(
        { user, account, credentials },
        options
      )

      if (unauthorizedOrError) return { ...unauthorizedOrError, cookies }

      const defaultToken = {
        name: user.name,
        email: user.email,
        picture: user.image,
        sub: user.id?.toString(),
      }

      const token = await callbacks.jwt({
        token: defaultToken,
        user,
        // @ts-expect-error
        account,
        isNewUser: false,
        trigger: "signIn",
      })

      // Clear cookies if token is null
      if (token === null) {
        cookies.push(...sessionStore.clean())
      } else {
        // Encode token
        const newToken = await jwt.encode({ ...jwt, token })

        // Set cookie expiry date
        const cookieExpires = new Date()
        cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)

        const sessionCookies = sessionStore.chunk(newToken, {
          expires: cookieExpires,
        })

        cookies.push(...sessionCookies)
      }

      // @ts-expect-error
      await events.signIn?.({ user, account })

      return { redirect: callbackUrl, cookies }
    }

    return {
      status: 500,
      body: `Error: Callback for provider type ${provider.type} not supported`,
      cookies,
    }
  } catch (e) {
    if (e instanceof OAuthCallbackError) {
      logger.error(e)
      // REVIEW: Should we expose original error= and error_description=
      // Should we use a different name for error= then, since we already use it for all kind of errors?
      url.searchParams.set("error", OAuthCallbackError.name)
      url.pathname += "/signin"
      return { redirect: url.toString(), cookies }
    }

    const error = new CallbackRouteError(e as Error, { provider: provider.id })

    logger.debug("callback route error details", { method, query, body })
    logger.error(error)
    url.searchParams.set("error", CallbackRouteError.name)
    url.pathname += "/error"
    return { redirect: url.toString(), cookies }
  }
}
