// user.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0

'use strict';

const path = require('path');

const mongoose = require('mongoose');

const User = mongoose.model('User');
const CoreUser = mongoose.model('CoreUser');
const UserBlock = mongoose.model('UserBlock');

const passport = require('passport');
const PassportLocal = require('passport-local');

const striptags = require('striptags');
const uuidv4 = require('uuid').v4;

const { SiteError, SiteService } = require('../../lib/site-lib');

class UserService extends SiteService {

  constructor (dtp) {
    super(dtp, module.exports);

    this.USER_SELECT = '_id username username_lc displayName picture';

    this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names'));

    this.populateUser = [
      {
        path: 'picture.large',
        strictPopulate: false,
      },
      {
        path: 'picture.small',
        strictPopulate: false,
      },
    ];
  }

  async start ( ) {
    await super.start();
    this.log.info(`starting ${module.exports.name} service`);

    this.registerPassportLocal();

    if (process.env.DTP_ADMIN === 'enabled') {
      this.registerPassportAdmin();
    }
  }

  async stop ( ) {
    this.log.info(`stopping ${module.exports.name} service`);
    await super.stop();
  }

  async create (userDefinition) {
    const NOW = new Date();
    const {
      crypto: cryptoService,
      email: mailService,
    } = this.dtp.services;

    try {
      userDefinition.email = userDefinition.email.trim().toLowerCase();

      // strip characters we don't want to allow in username
      userDefinition.username = userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '');
      const username_lc = userDefinition.username.toLowerCase();
      await this.checkUsername(username_lc);

      // test the email address for validity, blacklisting, etc.
      await mailService.checkEmailAddress(userDefinition.email);

      // test if we already have a user with this email address
      let user = await User.findOne({ 'email': userDefinition.email.toLowerCase().trim() }).lean();
      if (user) {
        throw new SiteError(400, `An account with email address ${userDefinition.email} already exists.`);
      }

      // test if we already have a user with this username
      user = await User.findOne({ username_lc }).lean();
      if (user) {
        throw new SiteError(400, `An account with username ${userDefinition.username} already exists.`);
      }

      const passwordSalt = uuidv4();
      const maskedPassword = cryptoService.maskPassword(passwordSalt, userDefinition.password);

      user = new User();
      user.created = NOW;

      user.email = userDefinition.email;
      user.username = userDefinition.username;
      user.username_lc = username_lc;
      user.displayName = striptags(userDefinition.displayName || userDefinition.username);

      user.passwordSalt = passwordSalt;
      user.password = maskedPassword;

      user.flags = {
        isAdmin: userDefinition.isAdmin || false,
        isModerator: userDefinition.isModerator || false,
        isEmailVerified: userDefinition.isEmailVerified || false,
      };

      user.permissions = {
        canLogin: userDefinition.canLogin || true,
        canChat: userDefinition.canChat || true,
        canComment: userDefinition.canComment || true,
        canReport: userDefinition.canReport || true,
        canAuthorPosts: userDefinition.canAuthorPosts || false,
        canAuthorPages: userDefinition.canAuthorPages || false,
        canPublishPosts: userDefinition.canPublishPosts || false,
        canPublishPages: userDefinition.canPublishPages || false,
      };

      user.optIn = {
        system: userDefinition.optInSystem || true,
        marketing: userDefinition.optInMarketing || false,
      };

      this.log.info('creating new user account', { email: userDefinition.email });
      await user.save();

      await this.sendWelcomeEmail(user);

      return user.toObject();
    } catch (error) {
      this.log.error('failed to create user', { error });
      throw error;
    }
  }

  async sendWelcomeEmail (user) {
    const { email: emailService } = this.dtp.services;
    if (process.env.DTP_EMAIL_SERVICE !== 'enabled') {
      this.log.info("Welcome email will not be sent: DTP_EMAIL_SERVICE is disabled, the system can't send email and will not try.");
      return;
    }

    /*
     * Remove all pending EmailVerify tokens for the User.
     */
    await emailService.removeVerificationTokensForUser(user);

    /*
     * Create the new/only EmailVerify token for the user. This will be the only
     * token accepted. Previous emails sent (if they were received) are invalid
     * after this.
     */
    const verifyToken = await emailService.createVerificationToken(user);

    /*
     * Send the welcome email using the new EmailVerify token so it can
     * construct a new, valid link to use for verifying the email address.
     */
    const templateModel = {
      site: this.dtp.config.site,
      recipient: user,
      emailVerifyToken: verifyToken.token,
    };
    const message = {
      from: process.env.DTP_EMAIL_SMTP_FROM,
      to: user.email,
      subject: `Welcome to ${this.dtp.config.site.name}!`,
      html: await emailService.renderTemplate('welcome', 'html', templateModel),
      text: await emailService.renderTemplate('welcome', 'text', templateModel),
    };
    await emailService.send(message);
  }

  async setEmailVerification (user, isVerified) {
    await User.updateOne(
      { _id: user._id },
      {
        $set: { 'flags.isEmailVerified': isVerified },
      },
    );
  }

  async emailOptOut (userId, category) {
    userId = mongoose.Types.ObjectId(userId);
    const user = await this.getUserAccount(userId);
    if (!user) {
      throw new SiteError(406, 'Invalid opt-out token');
    }

    const updateOp = { $set: { } };
    switch (category) {
      case 'marketing':
        updateOp.$set['optIn.marketing'] = false;
        break;
      case 'system':
        updateOp.$set['optIn.system'] = false;
        break;
      default:
        throw new SiteError(406, 'Invalid opt-out category');
    }

    await User.updateOne({ _id: userId }, updateOp);
  }

  async update (user, userDefinition) {
    if (!user.flags.canLogin) {
      throw SiteError(403, 'Invalid user account operation');
    }

    // strip characters we don't want to allow in username
    userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
    const username_lc = userDefinition.username.toLowerCase();

    userDefinition.displayName = striptags(userDefinition.displayName.trim());

    this.log.info('updating user', { userDefinition });
    await User.updateOne(
      { _id: user._id },
      {
        $set: {
          username: userDefinition.username,
          username_lc,
          displayName: userDefinition.displayName,
          bio: userDefinition.bio,
          'optIn.system': userDefinition['optIn.system'] === 'on',
          'optIn.marketing': userDefinition['optIn.marketing'] === 'on',
        },
      },
    );
  }

  async updateForAdmin (user, userDefinition) {
    // strip characters we don't want to allow in username
    userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
    const username_lc = userDefinition.username.toLowerCase();

    userDefinition.displayName = striptags(userDefinition.displayName.trim());
    if (userDefinition.bio) {
      userDefinition.bio = striptags(userDefinition.bio.trim());
    }

    if (userDefinition.badges) {
      userDefinition.badges = userDefinition.badges.split(',').map((badge) => striptags(badge.trim()));
    } else {
      userDefinition.badges = [ ];
    }

    this.log.info('updating user for admin', { userDefinition });
    await User.updateOne(
      { _id: user._id },
      {
        $set: {
          username: userDefinition.username,
          username_lc,
          displayName: userDefinition.displayName,
          bio: striptags(userDefinition.bio.trim()),
          badges: userDefinition.badges,

          'flags.isAdmin': userDefinition.isAdmin === 'on',
          'flags.isModerator': userDefinition.isModerator === 'on',
          'flags.isEmailVerified': userDefinition.isEmailVerified === 'on',

          'permissions.canLogin': userDefinition.canLogin === 'on',
          'permissions.canChat': userDefinition.canChat === 'on',
          'permissions.canComment': userDefinition.canComment === 'on',
          'permissions.canReport': userDefinition.canReport === 'on',
          'permissions.canAuthorPages': userDefinition.canAuthorPages === 'on',
          'permissions.canAuthorPosts': userDefinition.canAuthorPosts === 'on',
          'permissions.canPublishPages': userDefinition.canPublishPages === 'on',
          'permissions.canPublishPosts': userDefinition.canPublishPosts === 'on',

          'optIn.system': userDefinition.optInSystem === 'on',
          'optIn.marketing': userDefinition.optInMarketing === 'on',
        },
      },
    );
  }

  async updateSettings (user, userDefinition) {
    const { crypto: cryptoService } = this.dtp.services;

    const updateOp = { $set: { }, $unset: { } };

    // strip characters we don't want to allow in username
    updateOp.$set.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
    if (!updateOp.$set.username || (updateOp.$set.username.length === 0)) {
      throw new SiteError(400, 'Must include a username');
    }
    updateOp.$set.username_lc = updateOp.$set.username.toLowerCase();

    if (userDefinition.displayName && (userDefinition.displayName.length > 0)) {
      updateOp.$set.displayName = striptags(userDefinition.displayName.trim());
    } else {
      updateOp.$unset.displayName = 1;
    }

    if (userDefinition.bio && (userDefinition.bio.length > 0)) {
      updateOp.$set.bio = striptags(userDefinition.bio.trim());
    } else {
      updateOp.$unset.bio = 1;
    }

    if (userDefinition.password && userDefinition.password.length > 0) {
      updateOp.$set.passwordSalt = uuidv4();
      updateOp.$set.password = cryptoService.maskPassword(updateOp.$set.passwordSalt, userDefinition.password);
    }

    updateOp.$set.theme = userDefinition.theme || 'dtp-light';

    this.log.info('updating user settings', { userId: user._id });
    await User.updateOne({ _id: user._id }, updateOp);
  }

  async authenticate (account, options) {
    const { crypto } = this.dtp.services;

    options = Object.assign({
      adminRequired: false,
    }, options);

    const accountEmail = account.username.trim().toLowerCase();
    const accountUsername = await this.filterUsername(accountEmail);

    this.log.debug('locating user record', { accountEmail, accountUsername });
    let user = await User
      .findOne({
        $or: [
          { email: accountEmail },
          { username_lc: accountUsername },
        ]
      })
      .select('+passwordSalt +password +flags +optIn +permissions');

    if (!user) {
      throw new SiteError(404, 'Member credentials are invalid');
    }

    user = user.toObject();
    this.log.debug('user debug', { user });

    const maskedPassword = crypto.maskPassword(
      user.passwordSalt,
      account.password,
    );
    if (maskedPassword !== user.password) {
      throw new SiteError(403, 'Member credentials are invalid');
    }

    // remove these critical fields from the user object
    delete user.passwordSalt;
    delete user.password;

    if (options.adminRequired && !user.flags.isAdmin) {
      throw new SiteError(403, 'Admin privileges required');
    }

    return user;
  }

  async lookup (account, options) {
    options = Object.assign({ withEmail: false, withCredentials: false }, options);
    this.log.debug('locating user record', { account });

    const selects = [
      '_id', 'created',
      'username', 'username_lc',
      'displayName', 'picture',
      'flags', 'permissions',
    ];
    if (options.withEmail) {
      selects.push('email');
    }
    if (options.withCredentials) {
      selects.push('passwordSalt');
      selects.push('password');
    }

    const usernameRegex = new RegExp(`^${account.trim().toLowerCase()}.*`);

    /*
     * First, check our local db
     */
    let user = await User
      .findOne({
        $or: [
          { email: account.email },
          { username_lc: usernameRegex },
        ]
      })
      .select(selects.join(' '))
      .lean();

    if (user) {
      // found, mark as 'User'
      user.type = 'User';
    } else {
      // check for a matching CoreUser
      user = await CoreUser
        .findOne({
          $or: [
            { username_lc: usernameRegex },
          ]
        })
        .select(selects.join(' '))
        .lean();
      if (user) {
        // mark as CoreUser
        user.type = 'CoreUser';
      }
    }

    return user; // undefined means not found
  }

  registerPassportLocal ( ) {
    const options = {
      usernameField: 'username',
      passwordField: 'password',
      session: true,
    };
    passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this)));
  }

  async handleLocalLogin (username, password, done) {
    const now = new Date();
    this.log.info('handleLocalLogin', { username });
    try {
      const user = await this.authenticate({ username, password }, { adminRequired: false });
      await this.startUserSession(user, now);
      done(null, this.filterUserObject(user));
    } catch (error) {
      this.log.error('failed to process local user login', { error });
      done(error);
    }
  }

  registerPassportAdmin ( ) {
    const options = {
      usernameField: 'username',
      passwordField: 'password',
      session: true,
    };
    this.log.info('registering PassportJS admin strategy', { options });
    passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this)));
  }

  async handleAdminLogin (email, password, done) {
    const now = new Date();
    try {
      const user = await this.authenticate({ email, password }, { adminRequired: true });
      await this.startUserSession(user, now);
      done(null, this.filterUserObject(user));
    } catch (error) {
      this.log.error('failed to process admin user login', { error });
      done(error);
    }
  }

  async startUserSession (user, now) {
    user.type = 'User';
    await User.updateOne(
      { _id: user._id },
      {
        $set: { 'stats.lastLogin': now },
        $inc: { 'stats.loginCount': 1 },
      },
    );
  }

  filterUserObject (user) {
    const filteredUser = {
      _id: user._id,
      created: user.created,
      displayName: user.displayName,
      username: user.username,
      username_lc: user.username_lc,
      bio: user.bio,
      flags: user.flags,
      permissions: user.permissions,
      picture: user.picture,
    };
    if (filteredUser.flags && filteredUser.flags._id) {
      delete filteredUser.flags._id;
    }
    if (filteredUser.permissions && filteredUser.permissions._id) {
      delete filteredUser.permissions._id;
    }
    return filteredUser;
  }

  async getUserAccount (userId) {
    const user = await User
      .findById(userId)
      .select('+email +flags +permissions +optIn +picture')
      .populate(this.populateUser)
      .lean();
    if (!user) {
      throw new SiteError(404, 'Member account not found');
    }

    user.type = 'User';
    this.decorateUserObject(user);

    return user;
  }

  decorateUserObject (user) {
    user.hasAuthorPermissions = user.permissions.canAuthorPosts;
    user.hasPublishPermissions = user.permissions.canPublishPages || user.permissions.canPublishPosts;
    user.hasAuthorDashboard = user.hasAuthorPermissions || user.hasPublishPermissions;
  }

  async getUserAccounts (pagination, username) {
    let search = { };
    if (username) {
      search.username_lc = { $regex: `^${username.toLowerCase().trim()}` };
    }
    const users = await User
      .find(search)
      .sort({ username_lc: 1 })
      .select('+email +flags +permissions +optIn')
      .skip(pagination.skip)
      .limit(pagination.cpp)
      .lean()
    ;

    return users.map((user) => { user.type = 'User'; return user; });
  }
  
  async getAuthors (pagination) {
    const authors = await User
      .find({ 'permissions.canAuthorPosts': true })
      .sort({ displayName: 1 })
      .skip(pagination.skip)
      .limit(pagination.cpp)
      .populate(this.populateUser)
      .lean();
    const totalAuthorCount = await User.countDocuments( {'permissions.canAuthorPosts': true } );
  
    return {authors, totalAuthorCount};
  }

  async getUserProfile (userId) {
    let user;
    try {
      userId = mongoose.Types.ObjectId(userId); // will throw if invalid format
      user = User.findById(userId);
    } catch (error) {
      user = User.findOne({ username: userId });
    }
    user = await user
      .select('+email +flags +settings')
      .populate(this.populateUser)
      .lean();
    return user;
  }

  async getPublicProfile (type, username) {
    if (!username || (typeof username !== 'string')) {
      throw new SiteError(406, 'Invalid username');
    }

    username = username.trim().toLowerCase();
    if (username.length === 0) {
      throw new SiteError(406, 'Invalid username');
    }

    let user;
    switch (type) {
      case 'CoreUser':
        user = await CoreUser
          .findOne({ username_lc: username })
          .select('_id created username username_lc displayName bio picture header core')
          .populate(this.populateUser)
          .lean();
        if (user) {
          user.type = 'CoreUser';
        }
        break;

      case 'User':
        user = await User
          .findOne({ username_lc: username })
          .select('_id created username username_lc displayName bio picture header')
          .populate(this.populateUser)
          .lean();
        if (user) {
          user.type = 'User';
        }
        break;

      default:
        throw new SiteError(400, 'Invalid user account type');
    }

    return user;
  }

  async getRecent (maxCount = 3) {
    const users = User
      .find()
      .select(UserService.USER_SELECT)
      .sort({ created: -1 })
      .limit(maxCount)
      .lean();
    return users;
  }

  async setUserSettings (user, settings) {
    const {
      crypto: cryptoService,
      mail: mailService,
      phone: phoneService,
    } = this.dtp.platform.services;

    const update = { $set: { } };
    const actions = [ ];

    if (settings.name && (settings.name !== user.name)) {
      update.name = striptags(settings.name.trim());
      update.name_lc = update.name.toLowerCase();
      actions.push('Display name updated');
    }

    if (settings.username && (settings.username !== user.username)) {
      update.username = this.filterUsername(settings.username);
      await this.checkUsername(update.username);
    }

    if (settings.email && (settings.email !== user.email)) {
      settings.email = settings.email.toLowerCase().trim();
      await mailService.checkEmailAddress(settings.email);
      update.$set['flags.isEmailVerified'] = false;
      update.$set.email = settings.email;
      actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.');
    }

    /*
     * User is changing the phone number stored on the account.
     * "There's a lot to unpack here"
     */
    if (settings.phone) {
      // update the phone number (there's a lot going on here)
      try {
        update.$set.phone = await phoneService.processPhoneNumberInput(settings.phone);
      } catch (error) {
        throw error;
      }

      // un-verify the account's phone number
      update.$set['flags.isPhoneVerified'] = false;

      actions.push('Phone number updated and verification message sent. Please follow the instructions in the text message to complete the change of your mobile phone number.');
    }

    if (settings.password) {
      if (settings.password !== settings.passwordv) {
        throw new SiteError(400, 'Password and password verification do not match.');
      }
      update.$set.passwordSalt = uuidv4();
      update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password);
      actions.push('Password changed successfully.');
    }

    if (settings.theme) {
      update.$set['settings.theme'] = striptags(settings.theme.trim());
    }
    if (settings.language) {
      update.$set['settings.language'] = mongoose.Types.ObjectId(settings.language);
      actions.push('Interface language changed.');
    }

    await User.updateOne({ _id: user._id }, update);

    return actions;
  }

  async filterUsername (username) {
    return striptags(username.trim().toLowerCase()).replace(/\W/g, '');
  }

  async checkUsername (username) {
    if (!username || (typeof username !== 'string') || (username.length === 0)) {
      throw new SiteError(406, 'Invalid username');
    }
    if (this.reservedNames.includes(username.trim().toLowerCase())) {
      throw new SiteError(403, 'That username is reserved for system use');
    }

    const user = await User.findOne({ username: username}).select('username').lean();
    if (user) {
      this.log.alert('username is already registered', { username });
      throw new SiteError(403, 'Username is already registered');
    }
  }

  async recordProfileView (user, req) {
    const { resource: resourceService } = this.dtp.services;
    await resourceService.recordView(req, 'User', user._id);
  }

  async getTotalCount ( ) {
    return await User.estimatedDocumentCount();
  }

  async updatePhoto (user, file) {
    const { image: imageService } = this.dtp.services;
    const images = [
      {
        width: 512,
        height: 512,
        format: 'jpeg',
        formatParameters: {
          quality: 80,
        },
      },
      {
        width: 64,
        height: 64,
        format: 'jpeg',
        formatParameters: {
          compressionLevel: 9,
        },
      },
    ];
    await imageService.processImageFile(user, file, images);
    await User.updateOne(
      { _id: user._id },
      {
        $set: {
          'picture.large': images[0].image._id,
          'picture.small': images[1].image._id,
        },
      },
    );
  }

  async removePhoto (user) {
    const { image: imageService } = this.dtp.services;

    this.log.info('remove profile photo', { user: user._id });
    user = await this.getUserAccount(user._id);
    if (user.picture.large) {
      await imageService.deleteImage(user.picture.large);
    }
    if (user.picture.small) {
      await imageService.deleteImage(user.picture.small);
    }
    await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } });
  }

  async blockUser (userId, blockedUserId) {
    userId = mongoose.Types.ObjectId(userId);
    blockedUserId = mongoose.Types.ObjectId(blockedUserId);
    if (userId.equals(blockedUserId)) {
      throw new SiteError(406, "You can't block yourself");
    }
    await UserBlock.updateOne(
      { user: userId },
      {
        $addToSet: { blockedUsers: blockedUserId },
      },
      { upsert: true },
    );
  }

  async unblockUser (userId, blockedUserId) {
    userId = mongoose.Types.ObjectId(userId);
    blockedUserId = mongoose.Types.ObjectId(blockedUserId);
    if (userId.equals(blockedUserId)) {
      throw new SiteError(406, "You can't un-block yourself");
    }
    await UserBlock.updateOne(
      { user: userId },
      {
        $removeFromSet: { blockedUsers: blockedUserId },
      },
      { upsert: true },
    );
  }

  async updatePassword (user, password) {
    const { crypto: cryptoService } = this.dtp.services;

    const passwordSalt = uuidv4();
    const passwordHash = cryptoService.maskPassword(passwordSalt, password);

    this.log.info('updating user password', { userId: user._id });

    await User.updateOne(
      { _id: user._id },
      {
        $set: {
          passwordSalt: passwordSalt,
          password: passwordHash,
        }
      }
    );
  }

  /**
   * Updates the `lastAnnouncement` field of a User to the `created` date of the
   * specified announcement (for tracking last-seen announcements).
   * @param {User} user The user being updated
   * @param {Announcement} announcement The announcement being seen by the User
   */
   async setLastAnnouncement (user, announcement) {
    await User.updateOne(
      { _id: user._id },
      {
        $set: { lastAnnouncement: announcement.created },
      },
    );
  }
}

module.exports = {
  slug: 'user',
  name: 'user',
  create: (dtp) => { return new UserService(dtp); },
};
