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

'use strict';

const path = require('path');
const fs = require('fs');

const util = require('util');
const execFile = util.promisify(require('child_process').execFile);

const mime = require('mime');

const mongoose = require('mongoose');
const User = mongoose.model('User');

const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));

/**
 * A job to archive and ban a User (local).
 * 
 * 1. Immediately disable the specified User
 * 2. Create a .zip file of the User's content on storage
 * 3. Creates a UserArchive record for the file and User
 * 4. Ban the User (removes all of the User's content)
 * 5. Remove the User record from the database
 */
class ArchiveUserLocalJob extends SiteWorkerProcess {

  static get COMPONENT ( ) {
    return {
      logId: 'wrk:reeeper:archive-user-local:job',
      index: 'archiveUserLocalJob',
      className: 'ArchiveUserLocalJob',
    };
  }

  constructor (worker) {
    super(worker, ArchiveUserLocalJob.COMPONENT);
    this.jobs = new Set();
  }

  async start ( ) {
    await super.start();

    this.queue = await this.getJobQueue('reeeper', this.dtp.config.jobQueues.reeeper);

    this.log.info('registering job processor', { queue: this.queue.name });
    this.queue.process('archive-user-local', 1, this.processArchiveUserLocal.bind(this));
  }

  async stop ( ) {
    try {
      if (this.queue) {
        this.log.info('halting job queue', { jobCount: this.jobs.size });
        await this.queue.pause(true, false);
        delete this.queue;
      }
    } catch (error) {
      this.log.error('failed to halt job queue', { error });
      // fall through
    } finally {
      await super.stop();
    }
  }

  async processArchiveUserLocal (job) {
    const { user: userService } = this.dtp.services;
    try {
      job.data.archivePath = path.join('/tmp', this.dtp.pkg.name, 'archive-user-local');
      this.jobs.add(job);

      job.data.userId = mongoose.Types.ObjectId(job.data.userId);
      job.data.user = await userService.getLocalUserAccount(job.data.userId);

      job.data.workPath = path.join(job.data.archivePath, job.data.userId.toString());
      await fs.promises.mkdir(job.data.workPath, { recursive: true });

      /*
       * Save the User account data
       */
      await this.archiveUserData(job);

      /*
       * Disable the User account (which destroys their session and cookie(s))
       */
      await this.disableUser(job);

      /*
       * Archive the User's content to the workPath on the local file system.
       */
      await this.archiveUserChat(job);
      await this.archiveUserComments(job);
      await this.archiveUserStickers(job);
      await this.archiveUserImages(job);
      await this.archiveUserAttachments(job);

      /*
       * Archive DTP Sites specific content
       */
      await this.archiveUserPosts(job);

      /*
       * Create the .zip file archive, upload it to storage, and create the
       * UserArchive record.
       */
      await this.createArchiveFile(job);

      this.log.info('banning user', {
        user: {
          _id: job.data.userId,
          username: job.data.user.username,
        },
      });
      await userService.ban(job.data.user);

      this.log.info('removing user', {
        user: {
          _id: job.data.userId,
          username: job.data.user.username,
        },
      });
      await User.deleteOne({ _id: job.data.userId });
    } catch (error) {
      this.log.error('failed to archive user', { userId: job.data.userId, error });
      throw error;
    } finally {
      if (job.data.workPath) {
        this.log.info('cleaning up work directory');
        await fs.promises.rm(job.data.workPath, { force: true, recursive: true });

        delete job.data.workPath;
      }
      this.jobs.delete(job);
      this.log.info('job complete', { job: job.id });
    }
  }

  async archiveUserData (job) {
    // fetch the entire User record (all fields)
    job.data.fullUser = await User
      .findOne({ _id: job.data.user._id })
      .select('+email +passwordSalt +password +flags +permissions +optIn')
      .lean();
    if (!job.data.fullUser) {
      throw new Error('user does not exist');
    }

    const userFilename = path.join(job.data.workPath, `user-${job.data.user._id}.json`);
    await fs.promises.writeFile(userFilename, JSON.stringify(job.data.fullUser, null, 2));
  }

  async disableUser (job) {
    this.log.info('disabling local User account', {
      user: {
        _id: job.data.userId,
        username: job.data.user.username,
      },
    });
    await User.updateOne(
      { _id: job.data.user._id },
      {
        $set: {
          'flags.isAdmin': false,
          'flags.isModerator': false,
          'flags.isEmailVerified': false,
          'permissions.canLogin': false,
          'permissions.canChat': false,
          'permissions.canComment': false,
          'permissions.canReport': false,
          'optIn.system': false,
          'optIn.marketing': false,
        },
      },
    );
  }

  async archiveUserChat (job) {
    const ChatMessage = mongoose.model('ChatMessage');
    const ChatRoom = mongoose.model('ChatRoom');

    job.data.chatPath = path.join(job.data.workPath, 'chat');
    await fs.promises.mkdir(job.data.chatPath, { recursive: true });

    this.log.info('archiving user chat', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await ChatRoom
      .find({ owner: job.data.user._id })

      .lean()
      .cursor()
      .eachAsync(async (room) => {
        const roomFilename = path.join(job.data.workPath, 'chat', `room-${room._id}`);
        await fs.promises.writeFile(roomFilename, JSON.stringify(room, null, 2));
      });

    await ChatMessage
      .find({ author: job.data.user._id })
      .lean()
      .cursor()
      .eachAsync(async (message) => {
        const messageFilename = path.join(job.data.workPath, 'chat', `message-${message._id}.json`);
        await fs.promises.writeFile(messageFilename, JSON.stringify(message, null, 2));
      });
  }

  async archiveUserComments (job) {
    const Comment = mongoose.model('Comment');

    job.data.commentPath = path.join(job.data.workPath, 'comments');
    await fs.promises.mkdir(job.data.commentPath, { recursive: true });

    this.log.info('archiving user comments', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await Comment
      .find({ author: job.data.userId })
      .cursor()
      .eachAsync(async (comment) => {
        const commentFilename = path.join(job.data.commentPath, `comment-${comment._id}.json`);
        await fs.promises.writeFile(commentFilename, JSON.stringify(comment, null, 2));
      });
  }

  async archiveUserStickers (job) {
    const Sticker = mongoose.model('Sticker');
    const { minio: minioService } = this.dtp.services;

    job.data.stickerPath = path.join(job.data.workPath, 'stickers');
    await fs.promises.mkdir(job.data.stickerPath, { recursive: true });

    job.data.stickerMediaPath = path.join(job.data.stickerPath, 'media');
    await fs.promises.mkdir(job.data.stickerMediaPath, { recursive: true });

    this.log.info('archiving user stickers', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await Sticker
      .find({ owner: job.data.userId })
      .cursor()
      .eachAsync(async (sticker) => {
        const stickerFilename = path.join(job.data.stickerPath, `sticker-${sticker._id}.json`);
        await fs.promises.writeFile(stickerFilename, JSON.stringify(sticker, null, 2));

        if (sticker.original && sticker.original.bucket && sticker.orignal.key && sticker.encoded.type) {
          const originalExt = mime.getExtension(sticker.original.type);
          const originalFilename = path.join(job.data.stickerMediaPath, `sticker-${sticker._id}.original.${originalExt}`);
          await minioService.downloadFile({
            bucket: sticker.original.bucket,
            key: sticker.original.key,
            filePath: originalFilename,
          });
        }

        if (sticker.encoded && sticker.encoded.bucket && sticker.encoded.key && sticker.encoded.type) {
          const encodedExt = mime.getExtension(sticker.encoded.type);
          const encodedFilename = path.join(job.data.stickerMediaPath, `sticker-${sticker._id}.encoded.${encodedExt}`);
          await minioService.downloadFile({
            bucket: sticker.encoded.bucket,
            key: sticker.encoded.key,
            filePath: encodedFilename,
          });
        }
      });
  }

  async archiveUserImages (job) {
    const SiteImage = mongoose.model('Image');
    const { image: imageService } = this.dtp.services;

    job.data.imagePath = path.join(job.data.workPath, 'images');
    await fs.promises.mkdir(job.data.imagePath, { recursive: true });

    this.log.info('archiving user images', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await SiteImage
      .find({ owner: job.data.user._id })
      .cursor()
      .eachAsync(async (image) => {
        try {
          let imageExt = mime.getExtension(image.type);
          const imageFilename = path.join(job.data.imagePath, `image-${image._id}.${imageExt}`);
          const metadataFilename = path.join(job.data.imagePath, `image-${image._id}.metadata.json`);

          await imageService.downloadImage(image, imageFilename);
          await fs.promises.writeFile(metadataFilename, JSON.stringify(image.metadata, null, 2));

        } catch (error) {
          this.log.error('failed to download image', {
            image: { _id: image._id },
            error,
          });
        }
      });
  }

  async archiveUserPosts (job) {
    const Post = mongoose.model('Post');

    job.data.postPath = path.join(job.data.workPath, 'posts');
    await fs.promises.mkdir(job.data.postPath, { recursive: true });

    this.log.info('archiving user blog posts', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await Post
      .find({ author: job.data.user._id })
      .cursor()
      .eachAsync(async (post) => {
        const postFilename = path.join(job.data.postPath, `post-${post._id}.json`);
        try {
          await fs.promises.writeFile(postFilename, JSON.stringify(post, null, 2));
        } catch (error) {
          this.log.error('failed to write user blog post file', { post: { _id: post._id }, error });
          // fall through
        }
      });
  }

  async archiveUserAttachments (job) {
    const Attachment = mongoose.model('Attachment');
    const { minio: minioService } = this.dtp.services;

    job.data.attachmentPath = path.join(job.data.workPath, 'attachments');
    await fs.promises.mkdir(job.data.attachmentPath, { recursive: true });

    job.data.originalAttachmentPath = path.join(job.data.attachmentPath, 'original');
    await fs.promises.mkdir(job.data.originalAttachmentPath, { recursive: true });

    job.data.encodedAttachmentPath = path.join(job.data.attachmentPath, 'encoded');
    await fs.promises.mkdir(job.data.encodedAttachmentPath, { recursive: true });

    this.log.info('archiving user attachments', {
      user: {
        _id: job.data.user._id,
        username: job.data.user.username,
      },
    });

    await Attachment
      .find({ owner: job.data.user._id })
      .cursor()
      .eachAsync(async (attachment) => {
        try {
          /*
           * Write the JSON record archive
           */
          const metadataFilename = path.join(job.data.attachmentPath, `attachment-${attachment._id}.metadata.json`);
          await fs.promises.writeFile(metadataFilename, JSON.stringify(attachment, null, 2));

          /*
           * Download and save the original file (if present)
           */
          if (attachment.original && attachment.original.bucket && attachment.original.key) {
            let originalExt = mime.getExtension(attachment.original.mime);
            const originalFilename = path.join(job.data.originalAttachmentPath, `attachment-${attachment._id}.${originalExt}`);
            await minioService.downloadFile({
              bucket: attachment.original.bucket,
              key: attachment.original.key,
              filePath: originalFilename,
            });
          }

          /*
           * Download and save the encoded file (if present)
           */
          if (attachment.encoded && attachment.encoded.bucket && attachment.encoded.key) {
            let encodedExt = mime.getExtension(attachment.encoded.mime);
            const encodedFilename = path.join(job.data.encodedAttachmentPath, `attachment-${attachment._id}.${encodedExt}`);
            await minioService.downloadFile({
              bucket: attachment.encoded.bucket,
              key: attachment.encoded.key,
              filePath: encodedFilename,
            });
          }
        } catch (error) {
          this.log.error('failed to archive attachment', {
            attachment: { _id: attachment._id },
            error,
          });
        }
      });
  }

  async createArchiveFile (job) {
    const { minio: minioService } = this.dtp.services;
    try {
      job.data.zipFilename = path.join(job.data.archivePath, `user-${job.data.userId}.zip`);
      const zipArgs = [
        '-r', '-9', 
        job.data.zipFilename,
        `${job.data.userId}`,
      ];
      const options = {
        cwd: job.data.archivePath,
        encoding: 'utf8',
      };
      await execFile('/usr/bin/zip', zipArgs, options);

      const zipFileStat = await fs.promises.stat(job.data.zipFilename);
      this.log.info('zip archive created', { size: zipFileStat.size });

      job.data.archiveFile = {
        bucket: process.env.MINIO_ADMIN_BUCKET,
        key: `/user-archive/user-${job.data.userId}.zip`,
      };

      const response = await minioService.uploadFile({
        bucket: job.data.archiveFile.bucket,
        key: job.data.archiveFile.key,
        filePath: job.data.zipFilename,
        metadata: {
          job: {
            id: job.id,
          },
          user: job.data.user,
        }
      });

      this.log.info('creating user archive record', { etag: response.etag, size: zipFileStat.size });
      const UserArchive = mongoose.model('UserArchive');
      await UserArchive.create({
        created: job.data.startTime,
        user: {
          _id: job.data.userId,
          username: job.data.user.username,
          email: job.data.fullUser.email,
        },
        archive: {
          bucket: job.data.archiveFile.bucket,
          key: job.data.archiveFile.key,
          etag: response.etag,
          size: zipFileStat.size,
        }
      });
    } catch (error) {
      this.log.error('failed to create archive .zip file', {
        user: {
          _id: job.data.userId,
          username: job.data.user.username,
        },
      });
      throw error;
    } finally {
      try {
        await fs.promises.rm(job.data.zipFilename, { force: true });
      } catch (error) {
        this.log.error('failed to remove temp .zip file', { error });
      }
    }
  }
}

module.exports = ArchiveUserLocalJob;