import { Types } from "mongoose";
import { TokenData } from "../models";
import crypto from "crypto";
import bcrypt from "bcrypt";
import {
  TOKEN_EMAIL_CONFIRMATION,
  TOKEN_EMAIL_MFA,
  TOKEN_EMAIL_ORG_INVITATION,
  TOKEN_EMAIL_PASSWORD_RESET,
} from "../variables";
import { UnauthorizedRequestError } from "../utils/errors";
import { getSaltRounds } from "../config";

/**
 * Create and store a token in the database for purpose [type]
 * @param {Object} obj
 * @param {String} obj.type
 * @param {String} obj.email
 * @param {String} obj.phoneNumber
 * @param {Types.ObjectId} obj.organizationId
 * @returns {String} token - the created token
 */
const createTokenHelper = async ({
  type,
  email,
  phoneNumber,
  organizationId,
}: {
  type:
    | "emailConfirmation"
    | "emailMfa"
    | "organizationInvitation"
    | "passwordReset";
  email?: string;
  phoneNumber?: string;
  organizationId?: Types.ObjectId;
}) => {
  let token, expiresAt, triesLeft;
  // generate random token based on specified token use-case
  // type [type]
  switch (type) {
    case TOKEN_EMAIL_CONFIRMATION:
      // generate random 6-digit code
      token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
      expiresAt = new Date(new Date().getTime() + 86400000);
      break;
    case TOKEN_EMAIL_MFA:
      // generate random 6-digit code
      token = String(crypto.randomInt(Math.pow(10, 5), Math.pow(10, 6) - 1));
      triesLeft = 5;
      expiresAt = new Date(new Date().getTime() + 300000);
      break;
    case TOKEN_EMAIL_ORG_INVITATION:
      // generate random hex
      token = crypto.randomBytes(16).toString("hex");
      expiresAt = new Date(new Date().getTime() + 259200000);
      break;
    case TOKEN_EMAIL_PASSWORD_RESET:
      // generate random hex
      token = crypto.randomBytes(16).toString("hex");
      expiresAt = new Date(new Date().getTime() + 86400000);
      break;
    default:
      token = crypto.randomBytes(16).toString("hex");
      expiresAt = new Date();
      break;
  }

  interface TokenDataQuery {
    type: string;
    email?: string;
    phoneNumber?: string;
    organization?: Types.ObjectId;
  }

  interface TokenDataUpdate {
    type: string;
    email?: string;
    phoneNumber?: string;
    organization?: Types.ObjectId;
    tokenHash: string;
    triesLeft?: number;
    expiresAt: Date;
  }

  const query: TokenDataQuery = { type };
  const update: TokenDataUpdate = {
    type,
    tokenHash: await bcrypt.hash(token, await getSaltRounds()),
    expiresAt,
  };

  if (email) {
    query.email = email;
    update.email = email;
  }
  if (phoneNumber) {
    query.phoneNumber = phoneNumber;
    update.phoneNumber = phoneNumber;
  }
  if (organizationId) {
    query.organization = organizationId;
    update.organization = organizationId;
  }

  if (triesLeft) {
    update.triesLeft = triesLeft;
  }

  await TokenData.findOneAndUpdate(query, update, {
    new: true,
    upsert: true,
  });

  return token;
};

/**
 *
 * @param {Object} obj
 * @param {String} obj.email - email associated with the token
 * @param {String} obj.token - value of the token
 */
const validateTokenHelper = async ({
  type,
  email,
  phoneNumber,
  organizationId,
  token,
}: {
  type:
    | "emailConfirmation"
    | "emailMfa"
    | "organizationInvitation"
    | "passwordReset";
  email?: string;
  phoneNumber?: string;
  organizationId?: Types.ObjectId;
  token: string;
}) => {
  interface Query {
    type: string;
    email?: string;
    phoneNumber?: string;
    organization?: Types.ObjectId;
  }

  const query: Query = { type };

  if (email) {
    query.email = email;
  }
  if (phoneNumber) {
    query.phoneNumber = phoneNumber;
  }
  if (organizationId) {
    query.organization = organizationId;
  }

  const tokenData = await TokenData.findOne(query).select("+tokenHash");

  if (!tokenData) throw new Error("Failed to find token to validate");

  if (tokenData.expiresAt < new Date()) {
    // case: token expired
    await TokenData.findByIdAndDelete(tokenData._id);
    throw UnauthorizedRequestError({
      message: "MFA session expired. Please log in again",
      context: {
        code: "mfa_expired",
      },
    });
  }

  const isValid = await bcrypt.compare(token, tokenData.tokenHash);
  if (!isValid) {
    // case: token is not valid
    if (tokenData?.triesLeft !== undefined) {
      // case: token has a try-limit
      if (tokenData.triesLeft === 1) {
        // case: token is out of tries
        await TokenData.findByIdAndDelete(tokenData._id);
      } else {
        // case: token has more than 1 try left
        await TokenData.findByIdAndUpdate(
          tokenData._id,
          {
            triesLeft: tokenData.triesLeft - 1,
          },
          {
            new: true,
          }
        );
      }

      throw UnauthorizedRequestError({
        message: "MFA code is invalid",
        context: {
          code: "mfa_invalid",
          triesLeft: tokenData.triesLeft - 1,
        },
      });
    }

    throw UnauthorizedRequestError({
      message: "MFA code is invalid",
      context: {
        code: "mfa_invalid",
      },
    });
  }

  // case: token is valid
  await TokenData.findByIdAndDelete(tokenData._id);
};

export { createTokenHelper, validateTokenHelper };
