'use strict';

const AdmZip = require('adm-zip');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const fg = require('fast-glob');
const runParallelLimit = require('run-parallel-limit');
const download = require('download');
const Axios = require('axios');

const axios = Axios.create();
axios.defaults.headers.common = {};
axios.defaults.headers.put = {};
axios.defaults.headers.get = {};

const axiosConfig = {
  maxContentLength: Infinity,
  maxBodyLength: Infinity,
};

const defaultExcludes = [
  '.git',
  'package-lock.json',
  'node_modules/serverless',
  'node_modules/aws-sdk/**/*.d.ts',
  'node_modules/aws-sdk/**/*.examples.json',
  'node_modules/aws-sdk/**/*.md',
  'node_modules/aws-sdk/dist',
  'node_modules/aws-sdk/dist-tools',
  'node_modules/aws-sdk/scripts',
];

class Code {
  constructor(data) {
    this.fs = data.fs;
    this.api = data.api;
    this.org = data.org;
    this.service = data.service;
    this.instance = data.instance;

    this.src = this.fs.projectRoot; // todo this could be empty for download
    this.env = this.fs.readEnvFile() || {};
    this.exclude = defaultExcludes.concat(data.exclude || []);
    this.isForce = data.isForce || false; // uploads everything regardless of changes
    this.isChanged = data.isChanged || true; // there are changes to be synced

    this.previousMapDownloadUrl = null;
    this.changesUploadUrl = null;
    this.sourceDownloadUrl = null;

    this.files = {};
    this.map = {};
    this.previousMap = {};

    this.filesToUpload = [];
    this.filesToDelete = [];
  }

  async downloadPreviousMap() {
    if (this.isForce) {
      return null;
    }

    try {
      this.previousMap = (await axios.get(this.previousMapDownloadUrl, null, axiosConfig)).data;
      return this.previousMap;
    } catch (e) {
      return null;
    }
  }

  async getUrls(operation) {
    // 1000ms
    const options = {
      method: 'GET',
      endpoint: `/orgs/${this.org.orgName}/services/${this.service.serviceName}/instances/${this.instance.instanceName}/code/${operation}`,
    };

    const res = await this.api.request(options);

    this.instance.url = res.urls.instanceUrl;
    this.previousMapDownloadUrl = res.urls.previousMapDownloadUrl;
    this.sourceDownloadUrl = res.urls.sourceDownloadUrl;
    this.changesUploadUrl = res.urls.changesUploadUrl;
    this.deployToken = res.deployToken;

    return res;
  }

  async getUrlsAndPreviousMap(operation) {
    await this.getUrls(operation);

    if (!this.isForce) {
      await this.downloadPreviousMap();
    }
  }

  // todo should this move to FS?
  async getFilesAndMap() {
    const files = {};
    const map = {};

    let bytes = 0;

    // eslint-disable-next-line
    await new Promise(async (resolve, reject) => {
      const filesPaths = await fg('**/*', { cwd: this.src, dot: true, ignore: this.exclude });
      const tasks = filesPaths.map((filePath) => {
        return (callback) => {
          const absoluteFilePath = path.resolve(this.src, filePath);

          fs.readFile(absoluteFilePath, (readErr, file) => {
            if (readErr) {
              callback(readErr);
              return;
            }

            // this fixes the following permissions issue:
            // https://github.com/serverless/components/issues/687
            fs.stat(absoluteFilePath, (statErr, stat) => {
              if (statErr) {
                callback(statErr);
                return;
              }

              bytes += stat.size;

              files[filePath] = {
                file,
                stat,
              };

              map[filePath] = crypto.createHash('md5').update(file).digest('hex');

              callback();
            });
          });
        };
      });

      runParallelLimit(tasks, 2048, (err) => {
        if (err) {
          reject(err);
        } else {
          resolve();
        }
      });
    });

    if (bytes > 200000000) {
      throw new Error(
        'Your code size must be less than 200MB.  Try using Webpack or Parcel to reduce your code size.'
      );
    }

    this.map = map;
    this.files = files;

    return { map, files };
  }

  getFilesToUpload() {
    let filesToUpload = [];

    if (this.isForced) {
      filesToUpload = Object.keys(this.map);
    } else {
      Object.keys(this.map).forEach((filePath) => {
        if (!this.previousMap[filePath] || this.previousMap[filePath] !== this.map[filePath]) {
          filesToUpload.push(filePath);
        }
      });
    }

    this.filesToUpload = filesToUpload;

    return filesToUpload;
  }

  getFilesToDelete() {
    const filesToDelete = [];

    Object.keys(this.previousMap).forEach((filePath) => {
      if (!this.map[filePath]) {
        filesToDelete.push(filePath);
      }
    });

    this.filesToDelete = filesToDelete;

    return filesToDelete;
  }

  hasChanged() {
    this.getFilesToUpload();
    this.getFilesToDelete();

    if (this.filesToUpload.length !== 0 || this.filesToDelete.length !== 0) {
      this.isChanged = true;
    } else {
      this.isChanged = false;
    }

    return this.isChanged;
  }

  async zipChanges() {
    // 5ms
    const archive = new AdmZip();

    if (this.filesToDelete.length !== 0) {
      const filesToDeleteContent = JSON.stringify(this.filesToDelete);
      archive.addFile(
        'deleted.files',
        Buffer.alloc(filesToDeleteContent.length, filesToDeleteContent)
      );
    }

    const mapContent = JSON.stringify(this.map);
    archive.addFile('source.map', Buffer.alloc(mapContent.length, mapContent));

    for (const filePath of this.filesToUpload) {
      const mode = this.files[filePath].stat.mode & 0o100 ? 0o755 : 0o644;
      archive.addFile(filePath, this.files[filePath].file, null, mode);
    }

    this.zipBuffer = archive.toBuffer();

    return this.zipBuffer;
  }

  async uploadChanges() {
    // 500ms
    await axios.put(this.changesUploadUrl, this.zipBuffer, axiosConfig);
  }

  async downloadSource() {
    const buffer = await download(this.sourceDownloadUrl);
    const sourceZip = new AdmZip(buffer);
    sourceZip.deleteFile('handler.js');
    sourceZip.extractAllTo(this.src);
  }

  async processChanges() {
    const options = {
      method: 'POST',
      body: {
        deployToken: this.deployToken,
        env: this.env,
        isChanged: this.isChanged,
        isForced: this.isForced,
      },
      endpoint: `/orgs/${this.instance.orgName}/services/${this.instance.serviceName}/instances/${this.instance.instanceName}/code`,
    };

    await this.api.request(options);
  }

  async init(operation = 'deploy') {
    await Promise.all([this.getFilesAndMap(), this.getUrlsAndPreviousMap(operation)]);
  }

  async deploy() {
    // this gets called twice
    if (this.hasChanged()) {
      await this.zipChanges();
      await this.uploadChanges();
      await this.processChanges();
    }
  }

  async reload() {
    this.env = this.fs.readEnvFile() || {};
    await this.init();
    if (this.hasChanged()) {
      await this.deploy();
    }
  }
}

module.exports = Code;
