'use strict';

/*
 * SERVERLESS PLATFORM SDK: INSTANCE
 */
const crypto = require('crypto');
const Axios = require('axios');
const path = require('path');
const exec = require('child_process').exec;
const utils = require('./utils');
const api = require('./api');
const fg = require('fast-glob');
const fs = require('fs');
const AdmZip = require('adm-zip');
const runParallelLimit = require('run-parallel-limit');

// Create a new axios instance and make sure we clear the default axios headers
// as they cause a mismatch with the signature provided by aws/tencent cloud
const axios = Axios.create();
axios.defaults.headers.common = {};
axios.defaults.headers.put = {};
axios.defaults.headers.get = {};

// make sure axios handles large packages
const axiosConfig = {
  maxContentLength: Infinity,
  maxBodyLength: Infinity,
};

/**
 * Correctly formats an instanceId
 * @param {*} orgUid
 * @param {*} stageName
 * @param {*} appUid
 * @param {*} instanceName
 */
const generateId = (orgUid, stageName, appUid, instanceName) => {
  // Validate
  if (!orgUid || !stageName || !appUid || !instanceName) {
    throw new Error("'orgUid' 'stageName' 'appUid' and 'instanceName' are required");
  }
  return `${orgUid}.${stageName}.${appUid}.${instanceName}`;
};

/**
 * Create a new Instance
 * @param {*} orgName
 * @param {*} stageName
 * @param {*} appName
 * @param {*} instanceName
 */
const create = (orgName = null, stageName = null, appName = null, instanceName = null) => {
  // Validate
  if (!orgName || !stageName || !appName || !instanceName) {
    throw new Error("'orgName' 'stageName' 'appName' and 'instanceName' are required");
  }

  // Instance
  const instance = {};
  instance.orgName = orgName;
  instance.appName = appName;
  instance.stageName = stageName;
  instance.instanceName = instanceName;
  instance.componentName = null;
  instance.componentVersion = null;
  instance.inputs = {};
  instance.outputs = {};
  instance.state = {};
  instance.description = null;
  // Status
  instance.instanceStatus = 'inactive';
  instance.deploymentError = null;
  instance.deploymentErrorStack = null;
  instance.lastAction = null;
  // Dates
  instance.createdAt = Date.now();
  instance.updatedAt = Date.now();
  instance.lastDeployedAt = null;
  instance.lastActionAt = null;
  // Metrics
  instance.instanceMetrics = {};
  instance.instanceMetrics.actions = 0;
  instance.instanceMetrics.deployments = 0;
  instance.instanceMetrics.removes = 0;
  instance.instanceMetrics.errors = 0;

  return instance;
};

/**
 * Validates and (re)formats the component instance properties
 */
const validateAndFormat = (rawInstance) => {
  // Copy input object, otherwise the inputter will have unintended data modifications
  const instance = Object.assign({}, rawInstance);

  // Format Helper - If shortened properties are used, replace them with full properties
  if (instance.org) {
    instance.orgName = instance.org;
    delete instance.org;
  }
  if (instance.stage) {
    instance.stageName = instance.stage;
    delete instance.stage;
  }
  if (instance.app) {
    instance.appName = instance.app;
    delete instance.app;
  }
  if (instance.name) {
    instance.instanceName = instance.name;
    delete instance.name;
  }

  // Ensure all required properties exist
  if (!instance.orgName || !instance.stageName || !instance.appName || !instance.instanceName) {
    throw new Error("'orgName' 'stageName' 'appName' and 'instanceName' are required");
  }

  // Format - If shortened component syntax is used, expand into full syntax
  if (instance.component) {
    instance.componentName = instance.component.includes('@')
      ? instance.component.split('@')[0]
      : instance.component;
    instance.componentVersion = instance.component.includes('@')
      ? instance.component.split('@')[1]
      : '';
    delete instance.component;
  }

  // Ensure all required component properties exist
  if (!instance.componentName) {
    throw new Error("'componentName' is required");
  }

  // Ensure an inputs object exists
  if (!instance.inputs) {
    instance.inputs = {};
  }

  return instance;
};

/**
 * Create or update an Instance
 */
const save = async (sdk, instance = {}) => {
  // Validate
  instance = validateAndFormat(instance);

  // Send request
  return api.instance.save(sdk, instance);
};

/**
 * Get an Instance record by name
 */
const getByName = async (
  sdk,
  orgName = null,
  stageName = null,
  appName = null,
  instanceName = null,
  options = null
) => {
  return api.instance.get(sdk, orgName, appName, stageName, instanceName, options);
};

/**
 * List Instance records by Org
 */
const listByOrgName = async (sdk, orgName = null, orgUid = null) => {
  return api.instance.list(sdk, orgUid, orgName);
};

/*
 * Run a "src" hook, if one is specified
 */
const preRunSrcHook = async (src) => {
  if (typeof src === 'object' && src.hook && src.dist) {
    // First run the build hook, if "hook" and "dist" are specified
    const options = { cwd: src.src };
    return new Promise((resolve, reject) => {
      exec(src.hook, options, (err) => {
        if (err) {
          return reject(
            new Error(`Failed running "src.hook": "${src.hook}" due to the following error: ${err}`)
          );
        }
        return resolve(path.resolve(process.cwd(), src.dist));
      });
    });
  } else if (typeof src === 'object' && src.src) {
    src = path.resolve(src.src);
  } else if (typeof src === 'string') {
    src = path.resolve(src);
  }
  return src;
};

const getFilesToUpload = (map = {}, previousMap = {}, options) => {
  const filesToUpload = [];

  if (options.force) {
    return Object.keys(map);
  }

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

  return filesToUpload;
};

const getFilesToDelete = (map = {}, previousMap = {}) => {
  const filesToDelete = [];

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

  return filesToDelete;
};

const getPreviousMap = async (previousMapDownloadUrl) => {
  try {
    return (await axios.get(previousMapDownloadUrl, null, axiosConfig)).data;
  } catch (e) {
    return undefined;
  }
};

const preCache = async (sdk, { orgName, appName, stageName, instanceName }, options) => {
  const res = await api.instance.preCache(sdk, orgName, appName, stageName, instanceName);

  if (!options.force) {
    res.previousMap = await getPreviousMap(res.previousMapDownloadUrl);
  }

  return res;
};

const getFilesAndMap = async (inputs, targetDir = null) => {
  const { src, srcOriginal } = inputs;
  let exclude = ['.git'];
  if (typeof srcOriginal === 'object' && srcOriginal.exclude) {
    exclude = exclude.concat(srcOriginal.exclude);
  }
  // Ticket: https://app.asana.com/0/1200011502754281/1200314112289603/f
  // Do not upload node_modules when installDependency is true in config
  if (inputs.installDependency && !exclude.includes('node_modules')) {
    exclude.push('node_modules');
  }

  let include = [];
  if (typeof srcOriginal === 'object' && srcOriginal.include) {
    include = include.concat(srcOriginal.include);
  }

  const files = {};
  const map = {};

  let bytes = 0;

  // eslint-disable-next-line
  await new Promise(async (resolve, reject) => {
    const filesPaths = await fg('**/*', { cwd: src, dot: true, ignore: exclude });
    if (include.length > 0) {
      const includedPaths = await fg(include, { cwd: src });
      filesPaths.push(...includedPaths);
    }

    if (filesPaths.length === 0) {
      reject('指定的 src 目录为空, 请检查 serverless.yml 配置');
    }

    const tasks = filesPaths.map((filePath) => {
      return (callback) => {
        const absoluteFilePath = path.resolve(src, filePath);

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

          let targetFilePath = filePath;

          if (targetDir) {
            // replacing "\\" with "/" to make sure targetFilePath is Unix-style on windows. ref: https://github.com/mrmlnc/fast-glob#how-to-write-patterns-on-windows
            targetFilePath = path.join('.', targetDir, filePath).replace(/\\/g, '/');
          }

          fs.stat(absoluteFilePath, (statErr, stat) => {
            if (statErr) {
              callback(statErr);
              return;
            }

            bytes += stat.size;

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

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

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

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

  return { map, files, bytes };
};

const zipChanges = async (filesToUpload, filesToDelete, map, files) => {
  if (filesToUpload.length === 0 && filesToDelete.length === 0) {
    return null;
  }

  const zip = new AdmZip();

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

  const mapContent = JSON.stringify(map);
  zip.addFile('src.map', Buffer.from(mapContent));

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

  return zip.toBuffer();
};

const cache = async (sdk, instance, options) => {
  const { statusReceiver: statusReporter } = options;
  const targetDir =
    instance.inputs &&
    typeof instance.inputs.srcOriginal === 'object' &&
    instance.inputs.srcOriginal.targetDir;

  if (statusReporter) statusReporter('准备中');

  const [{ map, files, bytes }, { previousMap, changesUploadUrl, srcDownloadUrl }] =
    await Promise.all([
      getFilesAndMap(instance.inputs, targetDir),
      preCache(sdk, instance, options),
    ]);

  if (statusReporter) statusReporter('上传中');

  const filesToUpload = getFilesToUpload(map, previousMap, options);
  const filesToDelete = getFilesToDelete(map, previousMap, options);

  if (filesToUpload.length !== 0 || filesToDelete.length !== 0) {
    options.cacheOutdated = true;

    const zipBuffer = await zipChanges(filesToUpload, filesToDelete, map, files);

    await axios.put(changesUploadUrl, zipBuffer, axiosConfig);
  } else {
    options.cacheOutdated = false;
  }

  if (statusReporter) statusReporter();

  return { srcDownloadUrl, bytes };
};

/**
 * Run a method. "inputs" override serverless.yml inputs and should only be provided when using custom methods.
 */
const run = async (sdk, method = {}, instanceData = {}, credentials = {}, options = {}) => {
  let instance = JSON.parse(JSON.stringify(instanceData));

  // Validate method
  if (!method) {
    throw new Error('A "method" argument is required');
  }

  // Validate instance
  instance = validateAndFormat(instance);

  // Run source hook and upload source, if "src" input is used...
  // Save original src to present neatly in Dashboard/clients, or this will be replaced by the S3 location by the functions below
  if (instance.inputs.src) {
    instance.inputs.srcOriginal = JSON.parse(JSON.stringify(instance.inputs.src));
  }
  instance.inputs.src = await preRunSrcHook(instance.inputs.src);

  if (instance.inputs.src && typeof instance.inputs.src === 'string' && method === 'deploy') {
    if (process.argv.includes('--force')) {
      options.force = true;
    }

    const { srcDownloadUrl, bytes } = await cache(sdk, instance, options);

    if (bytes > 200000000 && bytes < 500000000) {
      console.log(
        '当前项目文件大小超过200MB，部署有可能会失败。如果失败建议使用 Webpack, Parcel 或 Layer 来减小项目文件大小后重试'
      );
    }

    if (bytes > 500000000) {
      throw new Error(
        '当前项目文件大小超过500MB，无法完成部署。建议使用 Webpack, Parcel 或 Layer 来减小项目文件大小或拆分项目后重新部署'
      );
    }

    instance.inputs.src = srcDownloadUrl;
  } else {
    delete instance.inputs.src; // remove src if we do not need to upload code
    return api.instance.run(sdk, instance, method, credentials, options);
  }

  await api.instance.run(sdk, instance, method, credentials, options);
  return null;
};

/**
 * Finish a run
 */
const runFinish = async (sdk, method = null, instance = {}) => {
  return api.instance.runFinish(sdk, instance, method);
};

/**
 * Run a deployment
 * @param {object} instance The Instance definition.
 * @param {object} credentials The credentials of the cloud providers required by the Instance.
 * @param {object} options Any options you wish to supply the Instance method.
 * @param {number} size The size in bytes of the source code, so that the Instance method can validate it beforehand.
 */
const deploy = async (sdk, instance, credentials, options) => {
  // Set defaults
  options.timeout = options.timeout || 600000; // 600000 = 10 minutes
  options.interval = options.interval || 500;

  // Set timer
  const startedAt = Date.now();

  // Perform Run
  await run(sdk, 'deploy', instance, credentials, options);

  if (options.dev && utils.isChinaUser()) {
    // in tencent case, we'll setup polling in component CLI after each deployment
    return null;
  }

  // Set up polling
  // Poll function calls the instance constantly to check its status
  const poll = async () => {
    // Check if timed out
    if (Date.now() - options.timeout > startedAt) {
      throw new Error(`Request timed out after ${options.timeout / 60000} minutes`);
    }

    // Fetch instance
    let instanceRecord = await getByName(
      sdk,
      instance.orgName || instance.org,
      instance.stageName || instance.stage,
      instance.appName || instance.app,
      instance.instanceName || instance.name
    );
    instanceRecord = instanceRecord.instance;

    // Sleep before calling again
    if (instanceRecord.instanceStatus === 'active') {
      return instanceRecord;
    } else if (instanceRecord.instanceStatus === 'error') {
      const error = new Error(instanceRecord.deploymentError);
      error.stack = instanceRecord.deploymentErrorStack;
      throw error;
    }
    await utils.sleep(options.interval);
    return poll();
  };

  return poll();
};

/**
 * Run a removal
 * @param {object} instance The Instance definition.
 * @param {object} credentials The credentials of the cloud providers required by the Instance.
 * @param {object} options Any options you wish to supply the Instance method.
 * @param {number} size The size in bytes of the source code, so that the Instance method can validate it beforehand.
 */
const remove = async (sdk, instance, credentials, options) => {
  // Set defaults
  options.timeout = options.timeout || 600000; // 600000 = 10 minutes
  options.interval = options.interval || 500;

  // Set timer
  const startedAt = Date.now();

  // Perform Run
  await run(sdk, 'remove', instance, credentials, options);

  // Set up polling
  // Poll function calls the instance constantly to check its status
  const poll = async () => {
    // Check if timed out
    if (Date.now() - options.timeout > startedAt) {
      throw new Error(`Request timed out after ${options.timeout / 1000} seconds`);
    }

    // Fetch instance
    let instanceRecord = await getByName(
      sdk,
      instance.orgName || instance.org,
      instance.stageName || instance.stage,
      instance.appName || instance.app,
      instance.instanceName || instance.name
    );
    instanceRecord = instanceRecord.instance;

    // Sleep before calling again
    if (instanceRecord.instanceStatus === 'inactive') {
      return instanceRecord;
    } else if (instanceRecord.instanceStatus === 'error') {
      const error = new Error(instanceRecord.deploymentError);
      error.stack = instanceRecord.deploymentErrorStack;
      throw error;
    }
    await utils.sleep(options.interval);
    return poll();
  };

  return poll();
};

module.exports = {
  generateId,
  validateAndFormat,
  create,
  save,
  getByName,
  listByOrgName,
  run,
  runFinish,
  deploy,
  remove,
};
