import bcrypt from "bcryptjs";
import { identity, isEmpty, pickBy } from "lodash";
import { DateTime, DurationObject } from "luxon";
import { Db, MongoError } from "mongodb";
import uuid from "uuid";

import { DeepPartial, Omit, Sub } from "coral-common/types";
import { dotize } from "coral-common/utils/dotize";
import {
  ConfirmEmailTokenExpired,
  DuplicateEmailError,
  DuplicateUserError,
  LocalProfileAlreadySetError,
  LocalProfileNotSetError,
  PasswordResetTokenExpired,
  TokenNotFoundError,
  UserAlreadyBannedError,
  UserAlreadyPremoderated,
  UserAlreadySuspendedError,
  UsernameAlreadySetError,
  UserNotFoundError,
} from "coral-server/errors";
import logger from "coral-server/logger";
import {
  Connection,
  ConnectionInput,
  Query,
  resolveConnection,
} from "coral-server/models/helpers";
import { TenantResource } from "coral-server/models/tenant";
import { DigestibleTemplate } from "coral-server/queue/tasks/mailer/templates";
import { users as collection } from "coral-server/services/mongodb/collections";

import {
  GQLBanStatus,
  GQLDIGEST_FREQUENCY,
  GQLPremodStatus,
  GQLSuspensionStatus,
  GQLTimeRange,
  GQLUSER_ROLE,
  GQLUsernameStatus,
  GQLUserNotificationSettings,
} from "coral-server/graph/schema/__generated__/types";

import {
  CommentStatusCounts,
  createEmptyCommentStatusCounts,
} from "../comment";
import { getLocalProfile, hasLocalProfile } from "./helpers";

export interface LocalProfile {
  type: "local";
  id: string;
  password: string;

  /**
   * passwordID is used to help protect against double password change race
   * conditions. Because the password cannot be compared against directly, this
   * ID can be used as it is only changed when the password is changed.
   */
  passwordID: string;

  /**
   * resetID is used during a password reset process to prevent replay attacks.
   * When a password reset email is sent, a resetID is associated with the
   * account and the token. When a given reset token is used, it is cleared from
   * the user, preventing the same reset URL from being used multiple times.
   */
  resetID?: string;
}

export interface OIDCProfile {
  type: "oidc";
  id: string;
  issuer: string;
  audience: string;
}

export interface SSOProfile {
  type: "sso";
  id: string;
  lastIssuedAt: Date;
}

export interface FacebookProfile {
  type: "facebook";
  id: string;
}

export interface GoogleProfile {
  type: "google";
  id: string;
}

/**
 * Profile is all the different profiles that a given User may have associated
 * with their account.
 */
export type Profile =
  | LocalProfile
  | OIDCProfile
  | SSOProfile
  | FacebookProfile
  | GoogleProfile;

export interface Token {
  readonly id: string;
  name: string;
  createdAt: Date;
}

/**
 * ModeratorNote ModeratorNote is a note left by a moderator on the subject of a user.
 */
export interface ModeratorNote {
  /**
   * id is the identifier of the Note.
   */
  id: string;
  /**
   * body is the content of the Note
   */
  body: string;
  /**
   * createdAt is the date in which the Note was created.
   */
  createdAt: Date;
  /**
   * createdBy is the Moderator that authored the Note
   */
  createdBy: string;
}

/**
 * SuspensionStatusHistory SuspensionStatusHistory is the list of all suspension
 * events against a specific User.
 */
export interface SuspensionStatusHistory {
  /**
   * id is a specific reference for a particular suspension status that will be
   * used internally to update suspension records.
   */
  id: string;

  /**
   * from represents a range of time where a user suspension applies.
   */
  from: GQLTimeRange;

  /**
   * createdBy is the ID for the User that suspended the User. If `null`, the
   * suspension was created by the system.
   */
  createdBy?: string;

  /**
   * createdAt is the time that the given suspension time frame was created.
   */
  createdAt: Date;

  /**
   * modifiedBy is the ID for the User that modified the suspension for this
   * User. If `null`, the suspension has not been edited, or has been edited by
   * the system.
   */
  modifiedBy?: string;

  /**
   * modifiedAt is the time that the date that the given suspension time frame
   * was edited at.
   */
  modifiedAt?: Date;

  /**
   * message is the email message content sent to the user.
   */
  message: string;
}

/**
 * SuspensionStatus stores the user suspension status as well as the history of
 * changes.
 */
export interface SuspensionStatus {
  /**
   * history is the list of all suspension events against a specific User.
   */
  history: SuspensionStatusHistory[];
}

/**
 * BanStatusHistory is the list of all ban events against a specific User.
 */
export interface BanStatusHistory {
  /**
   * id is a specific reference for a particular banned status that will be
   * used internally to update banned records.
   */
  id: string;

  /**
   * active, when true, indicates that the user is banned from this status.
   */
  active: boolean;

  /**
   * createdBy is the ID for the User that banned the User. If `null`, the ban
   * was created by the system.
   */
  createdBy?: string;

  /**
   * createdAt is the time that the given ban was added.
   */
  createdAt: Date;

  message?: string;
}

/**
 * BanStatus contains information about a ban for a given User.
 */
export interface BanStatus {
  /**
   * active when true, indicates that the given user is banned.
   */
  active: boolean;

  /**
   * history is the list of all ban events against a specific User.
   */
  history: BanStatusHistory[];
}

export interface UsernameHistory {
  /**
   * id is a specific reference for a particular username history that will be
   * used internally to update username records.
   */
  id: string;

  /**
   * username is the username that was assigned
   */
  username: string;

  /**
   * createdBy is the user that created this username
   */
  createdBy: string;

  /**
   * createdAt is the time the username was created
   */
  createdAt: Date;
}

export interface UsernameStatus {
  /**
   * history is the list of all usernames for this user
   */
  history: UsernameHistory[];
}

/**
 * PremodStatusHistory is the history of premod status changes
 * against a specific User.
 */
export interface PremodStatusHistory {
  /**
   * active when true, indicates that the given user is premodded.
   */
  active: boolean;
  /**
   * createdBy is the ID for the User that premodded the User. If `null`, the
   * premod was created by the system.
   */
  createdBy?: string;

  /**
   * createdAt is the time that the given premod status was set.
   */
  createdAt: Date;
}

/**
 * PremodStatus is the status of whether a user is set to mandatory premod
 */
export interface PremodStatus {
  /**
   * active when true, indicates that the given user is set to mandatory premod.
   */
  active: boolean;

  /**
   * history is a list of previous enable/disable of premod status
   */
  history: PremodStatusHistory[];
}

/**
 * UserStatus stores the user status information regarding moderation state.
 */
export interface UserStatus {
  /**
   * suspension stores the user suspension status as well as the history of
   * changes.
   */
  suspension: SuspensionStatus;

  /**
   * ban stores the user ban status as well as the history of changes.
   */
  ban: BanStatus;

  /**
   * username stores the history of username changes for this user.
   */
  username: UsernameStatus;

  /**
   * premod stores whether a user is set to mandatory premod and history of
   * premod status.
   */
  premod: PremodStatus;
}

/**
 * IgnoredUser is the entry describing a User being ignored.
 */
export interface IgnoredUser {
  /**
   * id is the ID of the User that was ignored.
   */
  id: string;

  /**
   * createdAt is the date that the User was ignored on.
   */
  createdAt: Date;
}

/**
 * Digest is the actual digest entry that is created every time a digest is
 * queued for aUser.
 */
export interface Digest {
  /**
   * template is a given digestable template that was generated during the
   * notification processing phase. This contains the context typically provided
   * to the individual notification emails that are sent.
   */
  template: DigestibleTemplate;

  /**
   * createdAt is the date that the digest entry was created at.
   */
  createdAt: Date;
}

export interface UserCommentCounts {
  status: CommentStatusCounts;
}

/**
 * User is someone that leaves Comments, and logs in.
 */
export interface User extends TenantResource {
  /**
   * id is the identifier of the User.
   */
  readonly id: string;

  /**
   * username is the name of the User visible to other Users.
   */
  username?: string;

  /**
   * avatar is the url to the avatar for a specific User.
   */
  avatar?: string;

  /**
   * email is the current email address for the User.
   */
  email?: string;

  /**
   *
   * badges are user display badges
   */
  badges?: string[];

  /**
   * emailVerificationID is used to store state regarding the verification state
   * of an email address to prevent replay attacks.
   */
  emailVerificationID?: string;

  /**
   * emailVerified when true indicates that the given email address has been verified.
   */
  emailVerified?: boolean;

  /**
   * profiles is the array of profiles assigned to the user. When a user deletes
   * their account, this is unset.
   */
  profiles?: Profile[];

  /**
   * tokens lists the access tokens associated with the account.
   */
  tokens: Token[];

  /**
   * role is the current role of the User.
   */
  role: GQLUSER_ROLE;

  /**
   * notifications stores the notification settings for the given User.
   */
  notifications: GQLUserNotificationSettings;

  /**
   * digests stores all the notification digests on the User that are scheduled
   * to be sent out based on the User's notification preferences.
   */
  digests: Digest[];

  /**
   * hasDigests is true when there is digests to send.
   */
  hasDigests?: boolean;

  /**
   * status stores the user status information regarding moderation state.
   */
  status: UserStatus;

  /**
   * ignoredUsers stores the users that have been ignored by this User.
   */
  ignoredUsers: IgnoredUser[];

  /**
   * moderatorNotes are notes left by moderators about the User.
   */
  moderatorNotes: ModeratorNote[];

  /**
   * lastDownloadedAt is the last time the user requested to download their
   * user data.
   */
  lastDownloadedAt?: Date;

  /**
   * createdAt is the time that the User was created at.
   */
  createdAt: Date;

  /**
   * scheduledDeletionDate is the time that a user is scheduled to be deleted.
   * If this is null, the user has not requested for their account to be deleted.
   */
  scheduledDeletionDate?: Date;

  /**
   * deletedAt is the time that this user was deleted from our system.
   */
  deletedAt?: Date;

  commentCounts: UserCommentCounts;
}

function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 10);
}

export interface FindOrCreateUserInput {
  id?: string;
  username?: string;
  avatar?: string;
  email?: string;
  badges?: string[];
  emailVerified?: boolean;
  role: GQLUSER_ROLE;
  profile: Profile;
}

/**
 * findOrCreateUserInput converts the FindOrCreateUserInput input into aUse.
 *
 * @param tenantID ID of the Tenant to create the user for
 * @param input the input for creating a User
 * @param now the current date
 */
async function findOrCreateUserInput(
  tenantID: string,
  { id = uuid.v4(), profile, ...input }: FindOrCreateUserInput,
  now: Date
): Promise<Readonly<User>> {
  // default are the properties set by the application when a new user is
  // created.
  const defaults: Sub<User, FindOrCreateUserInput> = {
    tenantID,
    tokens: [],
    ignoredUsers: [],
    status: {
      username: {
        history: [],
      },
      suspension: { history: [] },
      ban: { active: false, history: [] },
      premod: { active: false, history: [] },
    },
    notifications: {
      onReply: false,
      onFeatured: false,
      onModeration: false,
      onStaffReplies: false,
      digestFrequency: GQLDIGEST_FREQUENCY.NONE,
    },
    moderatorNotes: [],
    digests: [],
    createdAt: now,
    commentCounts: {
      status: createEmptyCommentStatusCounts(),
    },
  };

  if (input.username) {
    // Add the username history to the user.
    defaults.status.username.history.push({
      id: uuid.v4(),
      username: input.username,
      createdBy: id,
      createdAt: now,
    });
  }

  // Store the user's profiles in a new array.
  const profiles: Profile[] = [];

  // Mutate the profiles to ensure we mask handle any secrets.
  switch (profile.type) {
    case "local": {
      // Hash the user's password with bcrypt.
      const password = await hashPassword(profile.password);
      profiles.push({ ...profile, password });
      break;
    }
    default:
      // Push the profile onto the User.
      profiles.push(profile);
      break;
  }

  // Merge the defaults and the input together.
  return {
    ...defaults,
    ...input,
    profiles,
    id,
  };
}

export async function findOrCreateUser(
  mongo: Db,
  tenantID: string,
  input: FindOrCreateUserInput,
  now: Date
) {
  const user = await findOrCreateUserInput(tenantID, input, now);

  try {
    await collection(mongo).findOneAndUpdate(
      {
        tenantID,
        profiles: {
          $elemMatch: {
            id: input.profile.id,
            type: input.profile.type,
          },
        },
      },
      { $setOnInsert: user },
      {
        // False to return the updated document instead of the original
        // document.
        returnOriginal: false,
        upsert: true,
      }
    );
  } catch (err) {
    // Evaluate the error, if it is in regards to violating the unique index,
    // then return a duplicate User error.
    if (err instanceof MongoError && err.code === 11000) {
      // Check if duplicate index was about the email.
      if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) {
        throw new DuplicateEmailError(input.email!);
      }
      throw new DuplicateUserError();
    }

    throw err;
  }

  return user;
}

export type CreateUserInput = FindOrCreateUserInput;

export async function createUser(
  mongo: Db,
  tenantID: string,
  input: CreateUserInput,
  now: Date
) {
  const user = await findOrCreateUserInput(tenantID, input, now);

  try {
    // Insert it into the database. This may throw an error.
    await collection(mongo).insertOne(user);
  } catch (err) {
    // Evaluate the error, if it is in regards to violating the unique index,
    // then return a duplicate User error.
    if (err instanceof MongoError && err.code === 11000) {
      // Check if duplicate index was about the email.
      if (err.errmsg && err.errmsg.includes("tenantID_1_email_1")) {
        throw new DuplicateEmailError(input.email!);
      }

      throw new DuplicateUserError();
    }

    throw err;
  }

  return user;
}

export async function retrieveUser(mongo: Db, tenantID: string, id: string) {
  return collection(mongo).findOne({ tenantID, id });
}

export async function retrieveManyUsers(
  mongo: Db,
  tenantID: string,
  ids: string[]
) {
  const cursor = collection(mongo).find({
    tenantID,
    id: {
      $in: ids,
    },
  });

  const users = await cursor.toArray();

  return ids.map(id => users.find(user => user.id === id) || null);
}

export async function retrieveUserWithProfile(
  mongo: Db,
  tenantID: string,
  profile: Partial<Pick<Profile, "id" | "type">>
) {
  return collection(mongo).findOne({
    tenantID,
    profiles: {
      $elemMatch: profile,
    },
  });
}

export async function retrieveUserWithEmail(
  mongo: Db,
  tenantID: string,
  email: string
) {
  return collection(mongo).findOne({
    tenantID,
    $or: [
      {
        profiles: {
          $elemMatch: { id: email, type: "local" },
        },
      },
      { email },
    ],
  });
}

/**
 * updateUserRole updates a given User's role.
 *
 * @param mongo mongodb database to interact with
 * @param tenantID Tenant ID where the User resides
 * @param id ID of the User that we are updating
 * @param role new role to set to the User
 */
export async function updateUserRole(
  mongo: Db,
  tenantID: string,
  id: string,
  role: GQLUSER_ROLE
) {
  const result = await collection(mongo).findOneAndUpdate(
    { id, tenantID },
    { $set: { role } },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    throw new UserNotFoundError(id);
  }

  return result.value;
}

export async function verifyUserPassword(
  user: Pick<User, "profiles">,
  password: string,
  withEmail?: string
) {
  const profile = getLocalProfile(user, withEmail);
  if (!profile) {
    throw new LocalProfileNotSetError();
  }

  return bcrypt.compare(password, profile.password);
}

export async function updateUserPassword(
  mongo: Db,
  tenantID: string,
  id: string,
  password: string,
  passwordID: string
) {
  // Hash the password.
  const hashedPassword = await hashPassword(password);

  // Update the user with the new password.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that the document we're updating already has a local
      // profile associated with them.
      profiles: {
        $elemMatch: {
          type: "local",
          passwordID,
        },
      },
    },
    {
      $set: {
        // Update the passwordID with a new one.
        "profiles.$[profiles].passwordID": uuid(),
        "profiles.$[profiles].password": hashedPassword,
      },
    },
    {
      arrayFilters: [{ "profiles.type": "local" }],
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    const profile = getLocalProfile(user);
    if (!profile) {
      throw new LocalProfileNotSetError();
    }

    if (profile.passwordID !== passwordID) {
      throw new Error("passwordID mismatch");
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value || null;
}

export async function scheduleDeletionDate(
  mongo: Db,
  tenantID: string,
  userID: string,
  deletionDate: Date
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id: userID,
      tenantID,
    },
    {
      $set: {
        scheduledDeletionDate: deletionDate,
      },
    },
    {
      returnOriginal: false,
    }
  );

  if (!result.value) {
    throw new Error("Unable to update user deletion date.");
  }

  return result.value || null;
}

export async function clearDeletionDate(
  mongo: Db,
  tenantID: string,
  userID: string
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id: userID,
      tenantID,
    },
    {
      $unset: {
        scheduledDeletionDate: "",
      },
    },
    {
      // We want to return edited user so that
      // we send back the cleared scheduledDeletionDate
      // to the client
      returnOriginal: false,
    }
  );

  if (!result.value) {
    throw new Error("Unable to update user deletion date.");
  }

  return result.value || null;
}

export interface UpdateUserInput {
  email?: string;
  username?: string;
  badges?: string[];
  role?: GQLUSER_ROLE;
}

export async function updateUserFromSSO(
  mongo: Db,
  tenantID: string,
  id: string,
  update: UpdateUserInput,
  lastIssuedAt: Date
) {
  // Update the user with the new properties.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that the document we're updating already has a sso
      // profile associated with them.
      "profiles.type": "sso",
    },
    {
      $set: {
        "profiles.$[profiles].lastIssuedAt": lastIssuedAt,
        ...update,
      },
    },
    {
      arrayFilters: [{ "profiles.type": "sso" }],
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUserWithProfile(mongo, tenantID, {
      type: "sso",
      id,
    });
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value || null;
}

/**
 * setUserUsername will set the username of the User if the username hasn't
 * already been used before.
 *
 * @param mongo the database handle
 * @param tenantID the ID to the Tenant
 * @param id the ID of the User where we are setting the username on
 * @param username the username that we want to set
 */
export async function setUserUsername(
  mongo: Db,
  tenantID: string,
  id: string,
  username: string
) {
  // TODO: (wyattjoh) investigate adding the username previously used to an array.

  // The username wasn't found, so add it to the user.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      username: null,
    },
    {
      $set: {
        username,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Try to get the current user to discover what happened.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    if (user.username) {
      throw new UsernameAlreadySetError();
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * updateUsername will set the username of the User.
 *
 * @param mongo the database handle
 * @param tenantID the ID to the Tenant
 * @param id the ID of the User where we are setting the username on
 * @param username the username that we want to set
 * @param createdBy the user making the change
 */

export async function updateUserUsername(
  mongo: Db,
  tenantID: string,
  id: string,
  username: string,
  createdBy: string,
  now = new Date()
) {
  const usernameHistory: UsernameHistory = {
    id: uuid(),
    username,
    createdBy,
    createdAt: now,
  };

  const result = await collection(mongo).findOneAndUpdate(
    { id, tenantID },
    {
      $set: {
        username,
      },
      $push: {
        "status.username.history": usernameHistory,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );

  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * setUserEmail will set the email address of the User if they don't already
 * have one associated with them, and it hasn't been used before.
 *
 * @param mongo the database handle
 * @param tenantID the ID to the Tenant
 * @param id the ID of the User where we are setting the email address on
 * @param emailAddress the email address we want to set
 */
export async function setUserEmail(
  mongo: Db,
  tenantID: string,
  id: string,
  emailAddress: string
) {
  // Lowercase the email address.
  const email = emailAddress.toLowerCase();

  // Search to see if this email has been used before.
  let user = await collection(mongo).findOne({
    tenantID,
    email,
  });
  if (user) {
    throw new DuplicateEmailError(email);
  }

  // The email wasn't found, so try to update the User.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      email: null,
    },
    {
      $set: {
        email,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Try to get the current user to discover what happened.
    user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    if (user.email) {
      throw new UsernameAlreadySetError();
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * updateUserEmail will update a given User's email address to the one provided.
 *
 * @param mongo the database that we are interacting with
 * @param tenantID the Tenant ID of the Tenant where the User exists
 * @param id the User ID that we are updating
 * @param emailAddress email address that we are setting on the User
 * @param emailVerified whether email is verified
 */
export async function updateUserEmail(
  mongo: Db,
  tenantID: string,
  id: string,
  emailAddress: string,
  emailVerified = false
) {
  // Lowercase the email address.
  const email = emailAddress.toLowerCase();

  try {
    // The email wasn't found, so try to update the User.
    const result = await collection(mongo).findOneAndUpdate(
      {
        tenantID,
        id,
      },
      {
        $set: {
          email,
          emailVerified,
          "profiles.$[profiles].id": email,
        },
      },
      {
        arrayFilters: [{ "profiles.type": "local" }],
        returnOriginal: false,
      }
    );
    if (!result.value) {
      // Try to get the current user to discover what happened.
      const user = await retrieveUser(mongo, tenantID, id);
      if (!user) {
        throw new UserNotFoundError(id);
      }

      throw new Error("an unexpected error occurred");
    }
    return result.value;
  } catch (err) {
    if (err instanceof MongoError && err.code === 11000) {
      throw new DuplicateEmailError(email);
    }
    throw err;
  }
}

/**
 * updateUserAvatar will update the avatar associated with a User. If the avatar
 * is not provided, it will be unset.
 *
 * @param mongo the database that we are interacting with
 * @param tenantID the Tenant ID of the Tenant where the User exists
 * @param id the User ID that we are updating
 * @param avatar URL that the avatar exists at
 */
export async function updateUserAvatar(
  mongo: Db,
  tenantID: string,
  id: string,
  avatar?: string
) {
  // The email wasn't found, so try to update the User.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
    },
    {
      // This will ensure that if the avatar isn't provided, it will unset the
      // avatar on the User.
      [avatar ? "$set" : "$unset"]: {
        avatar: avatar ? avatar : 1,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Try to get the current user to discover what happened.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * setUserLocalProfile will set the local profile for a User if they don't
 * already have one associated with them and the profile doesn't exist on any
 * other User already.
 *
 * @param mongo the database handle
 * @param tenantID the ID to the Tenant
 * @param id the ID of the User where we are setting the local profile on
 * @param emailAddress the email address we want to set
 * @param password the password we want to set
 */
export async function setUserLocalProfile(
  mongo: Db,
  tenantID: string,
  id: string,
  emailAddress: string,
  password: string
) {
  // Lowercase the email address.
  const email = emailAddress.toLowerCase();

  // Try to see if this local profile already exists on a User.
  let user = await retrieveUserWithProfile(mongo, tenantID, {
    type: "local",
    id: email,
  });
  if (user) {
    throw new DuplicateEmailError(email);
  }

  // Hash the password.
  const hashedPassword = await hashPassword(password);

  // Create the profile that we'll use.
  const profile: LocalProfile = {
    type: "local",
    id: email,
    password: hashedPassword,
    passwordID: uuid(),
  };

  // The profile wasn't found, so add it to the User.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that the document we're updating does not contain a local
      // profile.
      "profiles.type": { $ne: "local" },
    },
    {
      $push: {
        profiles: profile,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Try to get the current user to discover what happened.
    user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // Check to see if the user has any local profile (not just a local profile
    // with a specific email).
    if (hasLocalProfile(user)) {
      throw new LocalProfileAlreadySetError();
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

export async function createUserToken(
  mongo: Db,
  tenantID: string,
  userID: string,
  name: string,
  now = new Date()
) {
  // Create the Token that we'll be adding to the User.
  const token: Readonly<Token> = {
    id: uuid.v4(),
    name,
    createdAt: now,
  };

  const result = await collection(mongo).findOneAndUpdate(
    {
      id: userID,
      tenantID,
    },
    {
      $push: { tokens: token },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    throw new UserNotFoundError(userID);
  }

  return {
    user: result.value,
    token,
  };
}

export async function deactivateUserToken(
  mongo: Db,
  tenantID: string,
  userID: string,
  id: string
) {
  // Try to remove the Token from the User.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id: userID,
      tenantID,
      "tokens.id": id,
    },
    {
      $pull: { tokens: { id } },
    },
    {
      // True to return the original document instead of the updated
      // document.
      returnOriginal: true,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, userID);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // Check to see if the User had that Token in the first place.
    if (!user.tokens.find(t => t.id === id)) {
      throw new TokenNotFoundError();
    }

    throw new Error("an unexpected error occurred");
  }

  // We have to typecast here because we know at this point that the record does
  // contain the Token.
  const token: Token = result.value.tokens.find(t => t.id === id) as Token;

  // Mutate the user in order to remove the Token from the list of Token's.
  const updatedUser: Readonly<User> = {
    ...result.value,
    tokens: result.value.tokens.filter(t => t.id !== id),
  };

  return {
    user: updatedUser,
    token,
  };
}

export type UserConnectionInput = ConnectionInput<User>;

export async function retrieveUserConnection(
  mongo: Db,
  tenantID: string,
  input: UserConnectionInput
): Promise<Readonly<Connection<Readonly<User>>>> {
  // Create the query.
  const query = new Query(collection(mongo)).where({ tenantID });

  // If a filter is being applied, filter it as well.
  if (input.filter) {
    query.where(input.filter);
  }

  return retrieveConnection(input, query);
}

async function retrieveConnection(
  input: UserConnectionInput,
  query: Query<User>
): Promise<Readonly<Connection<Readonly<User>>>> {
  // Apply the pagination arguments to the query.
  query.orderBy({ createdAt: -1 });
  if (input.after) {
    query.where({ createdAt: { $lt: input.after as Date } });
  }

  // Return a connection.
  return resolveConnection(query, input, user => user.createdAt);
}

/**
 * premodUser will set a user to mandatory premod.
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the user being banned
 * @param createdBy the ID of the user premodding
 * @param now the current date
 */
export async function premodUser(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  now = new Date()
) {
  // Create the new ban.
  const premodStatusHistory: PremodStatusHistory = {
    active: true,
    createdBy,
    createdAt: now,
  };

  // Try to update the user if the user isn't already banned.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      "status.premod.active": {
        $ne: true,
      },
    },
    {
      $set: {
        "status.premod.active": true,
      },
      $push: {
        "status.premod.history": premodStatusHistory,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ban operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // Check to see if the user is already banned.
    const premod = consolidateUserPremodStatus(user.status.premod);
    if (premod.active) {
      throw new UserAlreadyPremoderated();
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * removeUserPremod will lift a user premod  requirement
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the user having their ban lifted
 * @param createdBy the ID of the user lifting the premod
 * @param now the current date
 */
export async function removeUserPremod(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  now = new Date()
) {
  // Create the new ban.
  const premod: PremodStatusHistory = {
    active: false,
    createdBy,
    createdAt: now,
  };

  // Try to update the user if the user isn't already banned.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      "status.premod.active": true,
    },
    {
      $set: {
        "status.premod.active": false,
      },
      $push: {
        "status.premod.history": premod,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );

  if (!result.value) {
    // Get the user so we can figure out why the ban operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // The user wasn't banned already, so nothing needs to be done!
    return user;
  }

  return result.value;
}

/**
 * banUser will ban a specific user from interacting with the site.
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the user being banned
 * @param createdBy the ID of the user banning the above mentioned user
 * @param message message to banned user
 * @param now the current date
 */
export async function banUser(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  message?: string,
  now = new Date()
) {
  // Create the new ban.
  const banHistory: BanStatusHistory = {
    id: uuid(),
    active: true,
    createdBy,
    createdAt: now,
    message,
  };

  // Try to update the user if the user isn't already banned.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      "status.ban.active": {
        $ne: true,
      },
    },
    {
      $set: {
        "status.ban.active": true,
      },
      $push: {
        "status.ban.history": banHistory,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ban operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // Check to see if the user is already banned.
    const ban = consolidateUserBanStatus(user.status.ban);
    if (ban.active) {
      throw new UserAlreadyBannedError();
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * removeUserBan will lift a user ban from a User allowing them to interact with
 * the site again.
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the user having their ban lifted
 * @param createdBy the ID of the user lifting the ban
 * @param now the current date
 */
export async function removeUserBan(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  now = new Date()
) {
  // Create the new ban.
  const ban: BanStatusHistory = {
    id: uuid(),
    active: false,
    createdBy,
    createdAt: now,
  };

  // Try to update the user if the user isn't already banned.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      $or: [
        {
          "status.ban.active": {
            $ne: false,
          },
        },
        {
          "status.ban.history": {
            $size: 0,
          },
        },
      ],
    },
    {
      $set: {
        "status.ban.active": false,
      },
      $push: {
        "status.ban.history": ban,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ban operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // The user wasn't banned already, so nothing needs to be done!
    return user;
  }

  return result.value;
}

/**
 * suspendUser will suspend a user for a specific time range from interacting
 * with the site.
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the user being suspended
 * @param createdBy the ID of the user banning the above mentioned user
 * @param finish the date the suspension ends
 * @param message the message sent to suspended user in email
 * @param now the current date
 */
export async function suspendUser(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  finish: Date,
  message: string,
  now = new Date()
) {
  // Create the new suspension.
  const suspension: SuspensionStatusHistory = {
    id: uuid(),
    from: {
      start: now,
      finish,
    },
    createdBy,
    createdAt: now,
    message,
  };

  // Try to update the user if the user isn't already suspended.
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      "status.suspension.history": {
        $not: {
          $elemMatch: {
            "from.start": {
              $lte: now,
            },
            "from.finish": {
              $gt: now,
            },
          },
        },
      },
    },
    {
      $push: {
        "status.suspension.history": suspension,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the suspend operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // Check to see if the user is already suspended.
    const suspended = consolidateUserSuspensionStatus(
      user.status.suspension,
      now
    );
    if (suspended.active && suspended.until) {
      throw new UserAlreadySuspendedError(suspended.until);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * removeUserSuspensions will lift any active suspensions.
 *
 * @param mongo the mongo database handle
 * @param tenantID the Tenant's ID where the User exists
 * @param id the ID of the User having their suspension lifted
 * @param modifiedBy the ID of the User lifting the suspension
 * @param now the current date
 */
export async function removeActiveUserSuspensions(
  mongo: Db,
  tenantID: string,
  id: string,
  modifiedBy: string,
  now = new Date()
) {
  // Prepare the update payload.
  const update: DeepPartial<SuspensionStatusHistory> = {
    from: {
      finish: now,
    },
    modifiedAt: now,
    modifiedBy,
  };

  // Try to update the user suspension times.
  const result = await collection(mongo).findOneAndUpdate(
    { tenantID, id },
    {
      $set: dotize({
        "status.suspension.history.$[active]": update,
      }),
    },
    {
      arrayFilters: [
        // Change the finish date on all suspension records that indicate their
        // active time within our current range.
        {
          "active.from.start": { $lte: now },
          "active.from.finish": { $gt: now },
        },
      ],
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the suspend operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // The user wasn't already suspended, so nothing needs to be done!
    return user;
  }

  logger.debug({ result }, "finished update operation");

  return result.value;
}

export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
  Pick<BanStatus, "history">;

export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
  Pick<UsernameStatus, "history">;

export type ConsolidatedPremodStatus = Omit<GQLPremodStatus, "history"> &
  Pick<PremodStatus, "history">;

export function consolidateUsernameStatus(
  username: User["status"]["username"]
) {
  return username;
}

export function consolidateUserBanStatus(ban: User["status"]["ban"]) {
  return ban;
}

export function consolidateUserPremodStatus(premod: User["status"]["premod"]) {
  return premod;
}

export type ConsolidatedSuspensionStatus = Omit<
  GQLSuspensionStatus,
  "history"
> &
  Pick<SuspensionStatus, "history">;

export function consolidateUserSuspensionStatus(
  suspension: User["status"]["suspension"],
  now = new Date()
): ConsolidatedSuspensionStatus {
  return suspension.history.reduce(
    (status: ConsolidatedSuspensionStatus, history) => {
      // Check to see if we're currently suspended.
      if (history.from.start <= now && history.from.finish > now) {
        status.active = true;

        // Ensure that we have the furthest suspension finish time.
        if (!status.until || status.until < history.from.finish) {
          status.until = history.from.finish;
        }
      }

      return status;
    },
    {
      active: false,
      history: suspension.history,
    }
  );
}

export interface ConsolidatedUserStatus {
  suspension: ConsolidatedSuspensionStatus;
  ban: ConsolidatedBanStatus;
  premod: ConsolidatedPremodStatus;
}

export function consolidateUserStatus(
  status: User["status"],
  now = new Date()
): ConsolidatedUserStatus {
  // Return the status.
  return {
    suspension: consolidateUserSuspensionStatus(status.suspension, now),
    ban: consolidateUserBanStatus(status.ban),
    premod: consolidateUserPremodStatus(status.premod),
  };
}

/**
 * createOrRetrieveUserPasswordResetID will create/retrieve a password reset ID
 * on the User.
 *
 * @param mongo MongoDB instance to interact with
 * @param tenantID Tenant ID that the User exists on
 * @param id ID of the User that we are creating a reset ID with
 */
export async function createOrRetrieveUserPasswordResetID(
  mongo: Db,
  tenantID: string,
  id: string
): Promise<string> {
  // Create the ID.
  const resetID = uuid.v4();

  // Associate the resetID with the user.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that the document we're updating already has a local
      // profile associated with them and also doesn't have a resetID.
      profiles: {
        $elemMatch: {
          type: "local",
          resetID: null,
        },
      },
    },
    {
      $set: {
        "profiles.$[profiles].resetID": resetID,
      },
    },
    {
      arrayFilters: [{ "profiles.type": "local" }],
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    const localProfile = getLocalProfile(user);
    if (!localProfile) {
      throw new LocalProfileNotSetError();
    }

    if (localProfile.resetID) {
      return localProfile.resetID;
    }

    throw new Error("an unexpected error occurred");
  }

  return resetID;
}

export async function createOrRetrieveUserEmailVerificationID(
  mongo: Db,
  tenantID: string,
  id: string
): Promise<string> {
  // Create the ID.
  const emailVerificationID = uuid.v4();

  // Associate the resetID with the user.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that we don't set a emailVerificationID when there is one
      // already.
      emailVerificationID: null,
      $or: [{ emailVerified: false }, { emailVerified: null }],
    },
    {
      $set: {
        emailVerificationID,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    if (user.emailVerified) {
      throw new Error("email address has already been verified");
    }

    if (user.emailVerificationID) {
      return user.emailVerificationID;
    }

    throw new Error("an unexpected error occurred");
  }

  return emailVerificationID;
}

export async function resetUserPassword(
  mongo: Db,
  tenantID: string,
  id: string,
  password: string,
  passwordID: string,
  resetID: string
) {
  // Hash the password.
  const hashedPassword = await hashPassword(password);

  // Update the user with the new password.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      // This ensures that the document we're updating already has a local
      // profile associated with them and also matches the resetID specified.
      profiles: {
        $elemMatch: {
          type: "local",
          passwordID,
          resetID,
        },
      },
    },
    {
      $set: {
        // Update the passwordID with a new one.
        "profiles.$[profiles].passwordID": uuid(),
        "profiles.$[profiles].password": hashedPassword,
      },
      $unset: {
        "profiles.$[profiles].resetID": "",
      },
    },
    {
      arrayFilters: [{ "profiles.type": "local", "profiles.resetID": resetID }],
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    const profile = getLocalProfile(user);
    if (!profile) {
      throw new LocalProfileNotSetError();
    }

    if (profile.resetID !== resetID) {
      throw new PasswordResetTokenExpired("reset id mismatch");
    }

    if (profile.passwordID !== passwordID) {
      throw new PasswordResetTokenExpired("password id mismatch");
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value || null;
}

export async function confirmUserEmail(
  mongo: Db,
  tenantID: string,
  id: string,
  email: string,
  emailVerificationID: string
) {
  // Update the user with a confirmed email address.
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      id,
      email,
      emailVerificationID,
    },
    {
      $set: {
        emailVerified: true,
      },
      $unset: {
        emailVerificationID: "",
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    if (user.email !== email) {
      throw new ConfirmEmailTokenExpired("email mismatch");
    }

    if (user.emailVerificationID !== emailVerificationID) {
      throw new ConfirmEmailTokenExpired("email verification id mismatch");
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value || null;
}

export async function ignoreUser(
  mongo: Db,
  tenantID: string,
  id: string,
  ignoreUserID: string,
  now = new Date()
) {
  const ignoredUser: IgnoredUser = {
    id: ignoreUserID,
    createdAt: now,
  };

  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      ignoredUsers: {
        $not: {
          $eq: {
            id: ignoreUserID,
          },
        },
      },
    },
    {
      $push: { ignoredUsers: ignoredUser },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ignore operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // TODO: extract function
    if (
      user.ignoredUsers &&
      user.ignoredUsers.some(u => u.id === ignoreUserID)
    ) {
      // TODO: improve error
      throw new Error("user already ignored");
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

export async function removeUserIgnore(
  mongo: Db,
  tenantID: string,
  id: string,
  ignoreUserID: string
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
      "ignoredUsers.id": ignoreUserID,
    },
    {
      $pull: { ignoredUsers: { id: ignoreUserID } },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ignore operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    // TODO: extract function
    if (
      user.ignoredUsers &&
      user.ignoredUsers.every(u => u.id !== ignoreUserID)
    ) {
      // TODO: improve error
      throw new Error("user already not ignored");
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

export async function setUserLastDownloadedAt(
  mongo: Db,
  tenantID: string,
  id: string,
  now: Date
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
    },
    {
      $set: { lastDownloadedAt: now },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the ignore operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

export type NotificationSettingsInput = Partial<GQLUserNotificationSettings>;

export async function updateUserNotificationSettings(
  mongo: Db,
  tenantID: string,
  id: string,
  settings: NotificationSettingsInput
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
    },
    {
      $set: dotize({
        notifications: settings,
      }),
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the update operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * insertUserNotificationDigests will push the notification contexts onto the
 * User so that notifications can now be queued.
 *
 * @param mongo the database to put the notification digests into
 * @param tenantID the ID of the Tenant that this User exists on
 * @param id the ID of the User to insert the digests onto
 * @param templates the templates that represent the digests to be inserted
 * @param now the current time
 */
export async function insertUserNotificationDigests(
  mongo: Db,
  tenantID: string,
  id: string,
  templates: DigestibleTemplate[],
  now: Date
) {
  // Form the templates into digests to be sent.
  const digests: Digest[] = templates.map(template => ({
    template,
    createdAt: now,
  }));

  const result = await collection(mongo).findOneAndUpdate(
    {
      id,
      tenantID,
    },
    {
      $push: {
        digests: { $each: digests },
      },
      $set: {
        hasDigests: true,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    // Get the user so we can figure out why the update operation failed.
    const user = await retrieveUser(mongo, tenantID, id);
    if (!user) {
      throw new UserNotFoundError(id);
    }

    throw new Error("an unexpected error occurred");
  }

  return result.value;
}

/**
 * pullUserNotificationDigests will pull notification digests for a given User
 * so it can be added to the mailer queue.
 *
 * @param mongo the database to pull digests from
 * @param tenantID the tenant ID to pull digests for
 * @param frequency the frequency that we're scanning for to limit the digest
 *                  operation
 */
export async function pullUserNotificationDigests(
  mongo: Db,
  tenantID: string,
  frequency: GQLDIGEST_FREQUENCY
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      "notifications.digestFrequency": frequency,
      hasDigests: true,
    },
    { $set: { digests: [], hasDigests: false } },
    {
      // True to return the original document instead of the updated document.
      returnOriginal: true,
    }
  );

  return result.value || null;
}

/**
 * retrieveUserScheduledForDeletion will return a user that is scheduled for
 * deletion as well as create a reschedule date in the future.
 *
 * @param mongo the database to pull scheduled users to delete from
 * @param tenantID the tenant ID to pull users that have been scheduled for
 * deletion on
 * @param rescheduledDuration duration in which to reschedule
 * @param now the current time
 */
export async function retrieveUserScheduledForDeletion(
  mongo: Db,
  tenantID: string,
  rescheduledDuration: DurationObject,
  now: Date
) {
  const rescheduledDeletionDate = DateTime.fromJSDate(now)
    .plus(rescheduledDuration)
    .toJSDate();

  const result = await collection(mongo).findOneAndUpdate(
    {
      tenantID,
      scheduledDeletionDate: { $lte: now },
    },
    {
      $set: {
        scheduledDeletionDate: rescheduledDeletionDate,
      },
    },
    {
      // We want to get back the user with
      // modified scheduledDeletionDate
      returnOriginal: false,
    }
  );
  return result.value || null;
}

/**
 * createModeratorNote will add a note to a users account
 *
 * @param mongo the database to put the notification digests into
 * @param tenantID the ID of the Tenant that this User exists on
 * @param id the ID of the User who is the subject of the note
 * @param createdBy the ID of Moderator that is creating the note
 * @param note the contents of the note
 * @param now the current time that the note was created
 */
export async function createModeratorNote(
  mongo: Db,
  tenantID: string,
  id: string,
  createdBy: string,
  note: string,
  now = new Date()
) {
  const moderatorNote: ModeratorNote = {
    id: uuid(),
    createdAt: now,
    body: note,
    createdBy,
  };
  const result = await collection(mongo).findOneAndUpdate(
    { id, tenantID },
    {
      $push: {
        moderatorNotes: moderatorNote,
      },
    },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );
  if (!result.value) {
    throw new UserNotFoundError(id);
  }

  return result.value;
}

/**
 * deleteModeratorNote will remove a note from a user profile
 *
 * @param mongo the database to put the notification digests into
 * @param tenantID the ID of the Tenant that this User exists on
 * @param userID the ID of the user
 * @param id the ID of the note to delete
 * @param createdBy the ID of the note author
 */
export async function deleteModeratorNote(
  mongo: Db,
  tenantID: string,
  userID: string,
  id: string,
  createdBy: string
) {
  const result = await collection(mongo).findOneAndUpdate(
    {
      id: userID,
      tenantID,
    },
    {
      $pull: {
        moderatorNotes: { id, createdBy },
      },
    },
    {
      returnOriginal: false,
    }
  );
  if (!result.value) {
    throw new UserNotFoundError(id);
  }
  return result.value;
}

export async function updateUserCommentCounts(
  mongo: Db,
  tenantID: string,
  id: string,
  commentCounts: DeepPartial<UserCommentCounts>
) {
  // Update all the specific comment moderation queue counts.
  const update: DeepPartial<User> = { commentCounts };
  const $inc = pickBy(dotize(update), identity);
  if (isEmpty($inc)) {
    // Nothing needs to be incremented, just return the User.
    return retrieveUser(mongo, tenantID, id);
  }

  logger.trace({ update: { $inc } }, "incrementing user comment counts");

  const result = await collection(mongo).findOneAndUpdate(
    { id, tenantID },
    { $inc },
    {
      // False to return the updated document instead of the original
      // document.
      returnOriginal: false,
    }
  );

  return result.value || null;
}
