import { type Collection, type Db, type ObjectId, type CreateIndexesOptions } from 'mongodb';
import {
  type CreateUserServicePassword,
  type DatabaseInterfaceServicePassword,
  type User,
} from '@accounts/types';
import { toMongoID } from './utils';

type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

export interface MongoUser {
  _id?: string | object;
  username?: string;
  services: {
    password?: {
      bcrypt: string;
    };
  };
  emails?: [
    {
      address: string;
      verified: boolean;
    },
  ];
  [key: string]: any;
}

export interface MongoServicePasswordOptions {
  /**
   * Mongo database object.
   */
  database: Db;
  /**
   * The users collection name.
   * Default 'users'.
   */
  userCollectionName?: string;
  /**
   * The timestamps for the users collection.
   * Default 'createdAt' and 'updatedAt'.
   */
  timestamps?: {
    createdAt: string;
    updatedAt: string;
  };
  /**
   * Should the user collection use _id as string or ObjectId.
   * Default 'true'.
   */
  convertUserIdToMongoObjectId?: boolean;
  /**
   * Perform case intensitive query for user name.
   * Default 'true'.
   */
  caseSensitiveUserName?: boolean;
  /**
   * Function that generate the id for new objects.
   */
  idProvider?: () => string | object;
  /**
   * Function that generate the date for the timestamps.
   * Default to `(date?: Date) => (date ? date.getTime() : Date.now())`.
   */
  dateProvider?: (date?: Date) => any;
}

const defaultOptions = {
  userCollectionName: 'users',
  timestamps: {
    createdAt: 'createdAt',
    updatedAt: 'updatedAt',
  },
  convertUserIdToMongoObjectId: true,
  caseSensitiveUserName: true,
  dateProvider: (date?: Date) => (date ? date.getTime() : Date.now()),
};

export class MongoServicePassword implements DatabaseInterfaceServicePassword {
  // Merged options that can be used
  private options: MongoServicePasswordOptions & typeof defaultOptions;
  // Mongo database object
  private database: Db;
  // Mongo user collection
  private userCollection: Collection<
    PartialBy<User & { _id?: string | object }, 'id' | 'deactivated'>
  >;

  constructor(options: MongoServicePasswordOptions) {
    this.options = {
      ...defaultOptions,
      ...options,
      timestamps: { ...defaultOptions.timestamps, ...options.timestamps },
    };

    this.database = this.options.database;
    this.userCollection = this.database.collection(this.options.userCollectionName);
  }

  /**
   * Setup the mongo indexes needed for the password service.
   * @param options Options passed to the mongo native `createIndex` method.
   */
  public async setupIndexes(
    options: Omit<CreateIndexesOptions, 'unique' | 'sparse'> = {}
  ): Promise<void> {
    // Username index to allow fast queries made with username
    // Username is unique
    await this.userCollection.createIndex('username', {
      ...options,
      unique: true,
      sparse: true,
    });
    // Emails index to allow fast queries made with emails, a user can have multiple emails
    // Email address is unique
    await this.userCollection.createIndex('emails.address', {
      ...options,
      unique: true,
      sparse: true,
    });
    // Token index used to verify the email address of a user
    await this.userCollection.createIndex('services.email.verificationTokens.token', {
      ...options,
      sparse: true,
    });
    // Token index used to verify a password reset request
    await this.userCollection.createIndex('services.password.reset.token', {
      ...options,
      sparse: true,
    });
  }

  /**
   * Create a new user by providing an email and/or a username and password.
   * Emails are saved lowercased.
   */
  public async createUser({
    password,
    username,
    email,
    ...cleanUser
  }: CreateUserServicePassword): Promise<string> {
    const user: MongoUser = {
      ...cleanUser,
      services: {
        password: {
          bcrypt: password,
        },
      },
      [this.options.timestamps.createdAt]: this.options.dateProvider(),
      [this.options.timestamps.updatedAt]: this.options.dateProvider(),
    };
    if (username) {
      user.username = username;
    }
    if (email) {
      user.emails = [{ address: email.toLowerCase(), verified: false }];
    }
    if (this.options.idProvider) {
      user._id = this.options.idProvider();
    }
    const ret = await this.userCollection.insertOne(user);

    // keep ret.ops for compatibility with MongoDB 3.X, version 4.X uses insertedId
    return ((ret.insertedId ? ret.insertedId : (ret as any).ops[0]._id) as ObjectId).toString();
  }

  /**
   * Get a user by his id.
   * @param userId Id used to query the user.
   */
  public async findUserById(userId: string): Promise<User | null> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const user = await this.userCollection.findOne({ _id: id });
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  /**
   * Get a user by one of his emails.
   * Email will be lowercased before running the query.
   * @param email Email used to query the user.
   */
  public async findUserByEmail(email: string): Promise<User | null> {
    const user = await this.userCollection.findOne({
      'emails.address': email.toLowerCase(),
    });
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  /**
   * Get a user by his username.
   * Set the `caseSensitiveUserName` option to false if you want the username to be case sensitive.
   * @param email Email used to query the user.
   */
  public async findUserByUsername(username: string): Promise<User | null> {
    const filter = this.options.caseSensitiveUserName
      ? { username }
      : {
          $where: `obj.username && (obj.username.toLowerCase() === "${username.toLowerCase()}")`,
        };
    const user = await this.userCollection.findOne(filter);
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  /**
   * Return the user password hash.
   * If the user has no password set, will return null.
   * @param userId Id used to query the user.
   */
  public async findPasswordHash(userId: string): Promise<string | null> {
    const user = await this.findUserById(userId);
    return user?.services?.password?.bcrypt ?? null;
  }

  /**
   * Get a user by one of the email verification token.
   * @param token Verification token used to query the user.
   */
  public async findUserByEmailVerificationToken(token: string): Promise<User | null> {
    const user = await this.userCollection.findOne({
      'services.email.verificationTokens.token': token,
    });
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  /**
   * Get a user by one of the reset password token.
   * @param token Reset password token used to query the user.
   */
  public async findUserByResetPasswordToken(token: string): Promise<User | null> {
    const user = await this.userCollection.findOne({
      'services.password.reset.token': token,
    });
    if (user) {
      user.id = user._id.toString();
    }
    return user as User;
  }

  /**
   * Add an email address for a user.
   * @param userId Id used to update the user.
   * @param newEmail A new email address for the user.
   * @param verified Whether the new email address should be marked as verified.
   */
  public async addEmail(userId: string, newEmail: string, verified: boolean): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const ret = await this.userCollection.updateOne(
      { _id: id },
      {
        $addToSet: {
          emails: {
            address: newEmail.toLowerCase(),
            verified,
          },
        },
        $set: {
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
      }
    );
    if (
      ret.modifiedCount === 0 ||
      // keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
      ((ret as any).result && (ret as any).result.nModified === 0)
    ) {
      throw new Error('User not found');
    }
  }

  /**
   * Remove an email address for a user.
   * @param userId Id used to update the user.
   * @param email The email address to remove.
   */
  public async removeEmail(userId: string, email: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const ret = await this.userCollection.updateOne(
      { _id: id },
      {
        $pull: { emails: { address: email.toLowerCase() } },
        $set: {
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
      }
    );
    if (
      ret.modifiedCount === 0 ||
      // keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
      ((ret as any).result && (ret as any).result.nModified === 0)
    ) {
      throw new Error('User not found');
    }
  }

  /**
   * Marks the user's email address as verified.
   * @param userId Id used to update the user.
   * @param email The email address to mark as verified.
   */
  public async verifyEmail(userId: string, email: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const ret = await this.userCollection.updateOne(
      { _id: id, 'emails.address': email },
      {
        $set: {
          'emails.$.verified': true,
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
        $pull: { 'services.email.verificationTokens': { address: email } },
      }
    );
    if (
      ret.modifiedCount === 0 ||
      // keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
      ((ret as any).result && (ret as any).result.nModified === 0)
    ) {
      throw new Error('User not found');
    }
  }

  /**
   * Change the username of the user.
   * If the username already exists, the function will fail.
   * @param userId Id used to update the user.
   * @param newUsername A new username for the user.
   */
  public async setUsername(userId: string, newUsername: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const ret = await this.userCollection.updateOne(
      { _id: id },
      {
        $set: {
          username: newUsername,
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
      }
    );
    if (
      ret.modifiedCount === 0 ||
      // keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
      ((ret as any).result && (ret as any).result.nModified === 0)
    ) {
      throw new Error('User not found');
    }
  }

  /**
   * Change the password for a user.
   * @param userId Id used to update the user.
   * @param newPassword A new password for the user.
   */
  public async setPassword(userId: string, newPassword: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    const ret = await this.userCollection.updateOne(
      { _id: id },
      {
        $set: {
          'services.password.bcrypt': newPassword,
          [this.options.timestamps.updatedAt]: this.options.dateProvider(),
        },
        $unset: {
          'services.password.reset': '',
        },
      }
    );
    if (
      ret.modifiedCount === 0 ||
      // keep ret.result.nModified for compatibility with MongoDB 3.X, version 4.X uses modifiedCount
      ((ret as any).result && (ret as any).result.nModified === 0)
    ) {
      throw new Error('User not found');
    }
  }

  /**
   * Add an email verification token to a user.
   * @param userId Id used to update the user.
   * @param email Which address of the user's to link the token to.
   * @param token Random token used to verify the user email.
   */
  public async addEmailVerificationToken(
    userId: string,
    email: string,
    token: string
  ): Promise<void> {
    const _id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.userCollection.updateOne(
      { _id },
      {
        $push: {
          'services.email.verificationTokens': {
            token,
            address: email.toLowerCase(),
            when: this.options.dateProvider(),
          },
        },
      }
    );
  }

  /**
   * Add a reset password token to a user.
   * @param userId Id used to update the user.
   * @param email Which address of the user's to link the token to.
   * @param token Random token used to verify the user email.
   * @param reason Reason to use for the token.
   */
  public async addResetPasswordToken(
    userId: string,
    email: string,
    token: string,
    reason: string
  ): Promise<void> {
    const _id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.userCollection.updateOne(
      { _id },
      {
        $push: {
          'services.password.reset': {
            token,
            address: email.toLowerCase(),
            when: this.options.dateProvider(),
            reason,
          },
        },
      }
    );
  }

  /**
   * Remove all the reset password tokens for a user.
   * @param userId Id used to update the user.
   */
  public async removeAllResetPasswordTokens(userId: string): Promise<void> {
    const id = this.options.convertUserIdToMongoObjectId ? toMongoID(userId) : userId;
    await this.userCollection.updateOne(
      { _id: id },
      {
        $unset: {
          'services.password.reset': '',
        },
      }
    );
  }
}
