import { promisify } from "node:util";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import * as Boom from "@hapi/boom";
import * as Wreck from "@hapi/wreck";
import Credentials from "next-auth/providers/credentials";
import type { Adapter } from "next-auth/adapters";
import type { IncomingMessage } from "node:http";

const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion";
const CF_JWT_ALGOS = ["RS256"];

export type VerifyFn = (token: string) => Promise<any>;

/**
 * Returns a function that will accept a jwt and verify it against the cloudflare access details
 *
 * @param audience the cloudflare access audience id
 * @param domain the cloudflare access domain
 */
export const cfVerifier = (audience: string, domain: string): VerifyFn => {
  if (!audience || !domain)
    throw Boom.badImplementation(
      "Cloudflare configuration is missing. See project documentation."
    );
  const issuer = `https://${domain}`;
  const client = jwksClient({
    jwksUri: `${issuer}/cdn-cgi/access/certs`,
  });

  return async (token) => {
    const getKey = (header: any, callback: any) => {
      client.getSigningKey(header.kid, (err: any, key: any) => {
        if (err)
          throw Boom.serverUnavailable(
            "failed to fetch cloudflare access jwks"
          );
        callback(undefined, key.getPublicKey());
      });
    };

    const opts = {
      algorithms: CF_JWT_ALGOS,
      audience,
      issuer,
    };
    // @ts-expect-error: Too many args
    return promisify(jwt.verify)(token, getKey, opts);
  };
};

/**
 * Verifies the Cloudflare Access JWT and returns the decoded token's contents.
 * Throws if the token is missing or invalid.
 *
 * @param verifier the verification function
 * @param req the incoming http request to verify
 * @return the original token and the decoded contents.
 */
export const verifyRequest = async (
  verifier: VerifyFn,
  req: IncomingMessage
): Promise<{ token: string; decoded: any }> => {
  const token = req.headers[CF_JWT_HEADER_NAME] as string;
  if (token) {
    try {
      const decoded = await verifier(token);
      return { token, decoded };
    } catch (error) {
      console.error(error);
      throw Boom.unauthorized("invalid cloudflare access token");
    }
  }

  throw Boom.unauthorized("cloudflare access token missing");
};

/**
 * Fetches user identity information from cloudflare.
 *
 * @param domain the cloudflare access domain
 * @param token the encoded jwt token for the user
 * @see https://developers.cloudflare.com/access/setting-up-access/json-web-token#groups-within-a-jwt
 */
export const getIdentity = async (
  domain: string,
  token: string
): Promise<any> => {
  const { payload } = await Wreck.get(
    `https://${domain}/cdn-cgi/access/get-identity`,
    {
      headers: {
        Cookie: `CF_Authorization=${token}`,
      },
      json: true,
    }
  );
  return payload;
};

const cloudflareAccountProvider = "cloudflare-access";

const cloudflareAuthorizeCallback =
  (
    req: IncomingMessage,
    domain: string,
    verifier: VerifyFn,
    adapter: Adapter
  ): (() => Promise<any>) =>
  async () => {
    /*

    lots of little variables in here.

    token: the encoded jwt from cloudflare access
    decoded: the decoded jwt containing the content cloudflare gives us
    identity: we call the cloudflare access identity endpoint to retrieve more user identity information
              this data is identity provider specific, so the format is unknown
              it would be possible to support specific identity providers and have roles/groups
    profile: this is the accumulated user information we have that we will fetch/build the user record with
   */

    const { token, decoded } = await verifyRequest(verifier, req);

    const profile = {
      email: undefined,
      name: undefined,
      avatar: undefined,
    };
    if (decoded.email) profile.email = decoded.email;
    if (decoded.name) profile.name = decoded.name;
    const identity = await getIdentity(domain, token);

    if (identity.email) profile.email = identity.email;
    if (identity.name) profile.name = identity.name;

    if (!profile.email)
      throw new Error("cloudflare access authorization: email not found");

    const providerId = `cfaccess|${identity.idp.type}|${identity.idp.id}`;
    const providerAccountId = identity.user_uuid;

    if (!providerAccountId)
      throw new Error(
        "cloudflare access authorization: missing provider account id"
      );

    const {
      getUserByProviderAccountId,
      getUserByEmail,
      createUser,
      linkAccount,
    } =
      // @ts-expect-error: non-existent property
      await adapter.getAdapter({} as any);

    const userByProviderAccountId = await getUserByProviderAccountId(
      providerId,
      providerAccountId
    );
    if (userByProviderAccountId) {
      return userByProviderAccountId;
    }

    const userByEmail = await getUserByEmail(profile.email);
    if (userByEmail) {
      // we will not explicitly link accounts
      throw new Error(
        "cloudflare access authorization: user exists for email address, but is not linked."
      );
    }

    const user = await createUser(profile);

    // between the previous line and the next line exists a transactional bug
    // https://github.com/nextauthjs/next-auth/issues/876
    // hopefully we don't experience it

    await linkAccount(
      user.id,
      providerId,
      cloudflareAccountProvider,
      providerAccountId,
      // the following are unused but are specified for completness
      undefined,
      undefined,
      undefined
    );

    return user;
  };

/**
 * @param audience the cloudflare access audience id
 * @param domain the cloudflare access domain (including the .cloudflareaccess.com bit)
 * @param adapter the next-auth adapter used to talk to the backend
 * @param req the incoming request object used to parse the jwt from
 */
export const CloudflareAccessProvider = (
  audience: string,
  domain: string,
  adapter: Adapter,
  req: IncomingMessage
) => {
  const verifier = cfVerifier(audience, domain);

  return Credentials({
    id: cloudflareAccountProvider,
    name: "Cloudflare Access",
    credentials: {},
    authorize: cloudflareAuthorizeCallback(req, domain, verifier, adapter),
  });
};
