'use strict';

const _ = require('lodash');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const wait = require('timers-ext/promise/sleep');
const validate = require('./lib/validate');
const filesize = require('../../utils/filesize');
const ServerlessError = require('../../serverless-error');
const { log, style, progress } = require('@serverless/utils/log');

const mainProgress = progress.get('main');

class AwsDeployFunction {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options || {};
    this.packagePath =
      this.options.package ||
      this.serverless.service.package.path ||
      path.join(this.serverless.serviceDir || '.', '.serverless');
    this.provider = this.serverless.getProvider('aws');

    this.shouldEnsureFunctionState = false;

    Object.assign(this, validate);

    this.hooks = {
      'initialize': () => {
        const commandName = this.serverless.processedInput.commands.join(' ');
        if (commandName !== 'deploy function') return;
        log.notice();
        log.notice(
          `Deploying function ${this.options.function} to stage ${this.serverless
            .getProvider('aws')
            .getStage()} ${style.aside(`(${this.serverless.getProvider('aws').getRegion()})`)}`
        );
        log.info();
      },
      'before:deploy:function:initialize': () =>
        mainProgress.notice('Validating', { isMainEvent: true }),
      'deploy:function:initialize': async () => {
        await this.validate();
        await this.checkIfFunctionExists();
        this.checkIfFunctionChangesBetweenImageAndHandler();
      },

      'before:deploy:function:packageFunction': () =>
        mainProgress.notice('Retrieving function info', { isMainEvent: true }),
      'deploy:function:packageFunction': async () =>
        this.serverless.pluginManager.spawn('package:function'),

      'before:deploy:function:deploy': () =>
        mainProgress.notice('Packaging', { isMainEvent: true }),
      'deploy:function:deploy': async () => {
        if (!this.options['update-config']) {
          await this.deployFunction();
        }
        await this.updateFunctionConfiguration();
        if (this.shouldEnsureFunctionState) {
          await this.ensureFunctionState();
        }
        await this.serverless.pluginManager.spawn('aws:common:cleanupTempDir');
      },
    };
  }

  async checkIfFunctionExists() {
    // check if the function exists in the service
    this.options.functionObj = this.serverless.service.getFunction(this.options.function);

    // check if function exists on AWS
    const params = {
      FunctionName: this.options.functionObj.name,
    };

    const result = await (async () => {
      try {
        return await this.provider.request('Lambda', 'getFunction', params);
      } catch (error) {
        if (_.get(error, 'providerError.code') === 'ResourceNotFoundException') {
          const errorMessage = [
            `The function "${this.options.function}" you want to update is not yet deployed.`,
            ' Please run "serverless deploy" to deploy your service.',
            ' After that you can redeploy your services functions with the',
            ' "serverless deploy function" command.',
          ].join('');
          throw new ServerlessError(errorMessage, 'FUNCTION_NOT_YET_DEPLOYED');
        }
        throw error;
      }
    })();

    if (result) this.serverless.service.provider.remoteFunctionData = result;
  }

  checkIfFunctionChangesBetweenImageAndHandler() {
    const functionObject = this.serverless.service.getFunction(this.options.function);
    const remoteFunctionPackageType =
      this.serverless.service.provider.remoteFunctionData.Configuration.PackageType;

    if (functionObject.handler && remoteFunctionPackageType === 'Image') {
      throw new ServerlessError(
        `The function "${this.options.function}" you want to update with handler was previously packaged as an image. Please run "serverless deploy" to ensure consistent deploy.`,
        'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR'
      );
    }

    if (functionObject.image && remoteFunctionPackageType === 'Zip') {
      throw new ServerlessError(
        `The function "${this.options.function}" you want to update with image was previously packaged as zip file. Please run "serverless deploy" to ensure consistent deploy.`,
        'DEPLOY_FUNCTION_CHANGE_BETWEEN_HANDLER_AND_IMAGE_ERROR'
      );
    }
  }

  async normalizeArnRole(role) {
    if (typeof role === 'string') {
      if (role.indexOf(':') !== -1) {
        return role;
      }

      const roleResource = this.serverless.service.resources.Resources[role];

      if (roleResource.Type !== 'AWS::IAM::Role') {
        throw new ServerlessError(
          'Provided resource is not IAM Role',
          'ROLE_REFERENCES_NON_AWS_IAM_ROLE'
        );
      }
      const roleProperties = roleResource.Properties;
      if (!roleProperties.RoleName) {
        throw new ServerlessError(
          'Role resource missing RoleName property',
          'MISSING_ROLENAME_FOR_ROLE'
        );
      }
      const compiledFullRoleName = `${roleProperties.Path || '/'}${roleProperties.RoleName}`;

      const result = await this.provider.getAccountInfo();
      return `arn:${result.partition}:iam::${result.accountId}:role${compiledFullRoleName}`;
    }

    const data = await this.provider.request('IAM', 'getRole', {
      RoleName: role['Fn::GetAtt'][0],
    });
    return data.Arn;
  }

  async ensureFunctionState() {
    this.options.functionObj = this.serverless.service.getFunction(this.options.function);
    const params = {
      FunctionName: this.options.functionObj.name,
    };
    const startTime = Date.now();

    const callWithRetry = async () => {
      const result = await this.provider.request('Lambda', 'getFunction', params);
      if (
        result &&
        result.Configuration.State === 'Active' &&
        result.Configuration.LastUpdateStatus === 'Successful'
      ) {
        return;
      }
      const didOneMinutePass = Date.now() - startTime > 60 * 1000;
      if (didOneMinutePass) {
        throw new ServerlessError(
          'Ensuring function state timed out. Please try to deploy your function once again.',
          'DEPLOY_FUNCTION_ENSURE_STATE_TIMED_OUT'
        );
      }
      log.info(`Retrying ensure function state for function: ${this.options.function}.`);
      await wait(500);
      await callWithRetry();
    };

    await callWithRetry();
  }

  async callUpdateFunctionConfiguration(params) {
    const startTime = Date.now();

    const callWithRetry = async () => {
      try {
        await this.provider.request('Lambda', 'updateFunctionConfiguration', params);
      } catch (err) {
        const didOneMinutePass = Date.now() - startTime > 60 * 1000;

        if (err.providerError && err.providerError.code === 'ResourceConflictException') {
          if (didOneMinutePass) {
            throw new ServerlessError(
              'Retry timed out. Please try to deploy your function once again.',
              'DEPLOY_FUNCTION_CONFIGURATION_UPDATE_TIMED_OUT'
            );
          }
          log.info(
            `Retrying configuration update for function: ${this.options.function}. Reason: ${err.message}`
          );
          await wait(1000);
          await callWithRetry();
        } else {
          throw err;
        }
      }
    };
    await callWithRetry();
  }

  async updateFunctionConfiguration() {
    const functionObj = this.options.functionObj;
    const providerObj = this.serverless.service.provider;
    const remoteFunctionConfiguration =
      this.serverless.service.provider.remoteFunctionData.Configuration;
    const params = {
      FunctionName: functionObj.name,
    };

    const kmsKeyArn = functionObj.kmsKeyArn || providerObj.kmsKeyArn;

    if (kmsKeyArn) {
      params.KMSKeyArn = kmsKeyArn;
    }

    if (params.KMSKeyArn && params.KMSKeyArn === remoteFunctionConfiguration.KMSKeyArn) {
      delete params.KMSKeyArn;
    }

    if (functionObj.snapStart) {
      params.SnapStart = {
        ApplyOn: 'PublishedVersions',
      };
    }

    if (
      functionObj.description &&
      functionObj.description !== remoteFunctionConfiguration.Description
    ) {
      params.Description = functionObj.description;
    }

    if (functionObj.handler && functionObj.handler !== remoteFunctionConfiguration.Handler) {
      params.Handler = functionObj.handler;
    }

    if (functionObj.memorySize) {
      params.MemorySize = functionObj.memorySize;
    } else if (providerObj.memorySize) {
      params.MemorySize = providerObj.memorySize;
    }

    if (params.MemorySize && params.MemorySize === remoteFunctionConfiguration.MemorySize) {
      delete params.MemorySize;
    }

    if (functionObj.timeout) {
      params.Timeout = functionObj.timeout;
    } else if (providerObj.timeout) {
      params.Timeout = providerObj.timeout;
    }

    if (params.Timeout && params.Timeout === remoteFunctionConfiguration.Timeout) {
      delete params.Timeout;
    }

    // Check if we have remotely managed layers and add them to the update call
    // if they exist in the remote function configuration.
    const isConsoleSdkLayerArn = RegExp.prototype.test.bind(
      /(?:177335420605|321667558080):layer:sls-/u
    );
    const serverlessConsoleLayerArns = (remoteFunctionConfiguration.Layers || [])
      .filter(({ Arn: arn }) => isConsoleSdkLayerArn(arn))
      .map(({ Arn }) => Arn);
    const hasServerlessConsoleLayers = serverlessConsoleLayerArns.length > 0;
    if (!functionObj.layers || !functionObj.layers.some(_.isObject)) {
      // We need to initialize to an empty array so if a layer is removed
      // we will send an empty Layers array in the update call to remove any layers.
      // If there are no layers in the remove config this property will be set to undefined anyway.
      params.Layers = functionObj.layers || providerObj.layers || [];

      if (!remoteFunctionConfiguration.Layers) {
        remoteFunctionConfiguration.Layers = [];
      }

      if (hasServerlessConsoleLayers) {
        for (const layer of serverlessConsoleLayerArns) {
          if (!params.Layers.includes(layer)) {
            params.Layers.push(layer);
          }
        }
      }

      // Do not attach layers to the update call if the layers did not change.
      if (
        params.Layers &&
        remoteFunctionConfiguration.Layers &&
        _.isEqual(
          new Set(params.Layers),
          new Set(remoteFunctionConfiguration.Layers.map((layer) => layer.Arn))
        )
      ) {
        delete params.Layers;
      }
    }

    if (
      functionObj.onError &&
      !_.isObject(functionObj.onError) &&
      _.get(remoteFunctionConfiguration, 'DeadLetterConfig.TargetArn', null) !== functionObj.onError
    ) {
      params.DeadLetterConfig = {
        TargetArn: functionObj.onError,
      };
    }

    // Add empty environment object if it does not exist
    // so when we do the comparison below it will be equal to an empty object
    params.Environment = {
      Variables: {},
    };
    if (!remoteFunctionConfiguration.Environment) {
      remoteFunctionConfiguration.Environment = {
        Variables: {},
      };
    }

    if (functionObj.environment || providerObj.environment) {
      params.Environment.Variables = Object.assign(
        {},
        providerObj.environment,
        functionObj.environment
      );
    }
    if (Object.values(params.Environment.Variables).some((value) => _.isObject(value))) {
      delete params.Environment;
    } else {
      Object.keys(params.Environment.Variables).forEach((key) => {
        // taken from the bash man pages
        if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
          const errorMessage = 'Invalid characters in environment variable';
          throw new ServerlessError(errorMessage, 'DEPLOY_FUNCTION_INVALID_ENV_VARIABLE');
        }

        if (params.Environment.Variables[key] != null) {
          params.Environment.Variables[key] = String(params.Environment.Variables[key]);
        }
      });
    }
    // If we detected remotely managed layers, we need to add the environment variables
    // that are managed by the Serverless Console to the update call so they do not get removed.
    if (params.Environment && hasServerlessConsoleLayers) {
      const consoleEnvironmentVariableNames = [
        'AWS_LAMBDA_EXEC_WRAPPER',
        'SLS_ORG_ID',
        'SLS_DEV_MODE_ORG_ID',
        'SLS_DEV_TOKEN',
        'SERVERLESS_PLATFORM_STAGE',
      ];
      const remoteVariables = remoteFunctionConfiguration.Environment.Variables;
      const localVariables = params.Environment.Variables;
      for (const variableName of consoleEnvironmentVariableNames) {
        if (remoteVariables[variableName] && !localVariables[variableName]) {
          localVariables[variableName] = remoteVariables[variableName];
        }
      }
    }

    if (
      params.Environment &&
      remoteFunctionConfiguration.Environment &&
      _.isEqual(params.Environment.Variables, remoteFunctionConfiguration.Environment.Variables)
    ) {
      delete params.Environment;
    }

    if (functionObj.vpc || providerObj.vpc) {
      const vpc = functionObj.vpc || providerObj.vpc;
      params.VpcConfig = {};

      if (Array.isArray(vpc.securityGroupIds) && !vpc.securityGroupIds.some(_.isObject)) {
        params.VpcConfig.SecurityGroupIds = vpc.securityGroupIds;
      }

      if (Array.isArray(vpc.subnetIds) && !vpc.subnetIds.some(_.isObject)) {
        params.VpcConfig.SubnetIds = vpc.subnetIds;
      }

      const didVpcChange = () => {
        const remoteConfigToCompare = { SecurityGroupIds: [], SubnetIds: [] };
        if (remoteFunctionConfiguration.VpcConfig) {
          remoteConfigToCompare.SecurityGroupIds = new Set(
            remoteFunctionConfiguration.VpcConfig.SecurityGroupIds || []
          );
          remoteConfigToCompare.SubnetIds = new Set(
            remoteFunctionConfiguration.VpcConfig.SubnetIds || []
          );
        }
        const localConfigToCompare = {
          SecurityGroupIds: new Set(params.VpcConfig.SecurityGroupIds || []),
          SubnetIds: new Set(params.VpcConfig.SubnetIds || []),
        };
        return _.isEqual(remoteConfigToCompare, localConfigToCompare);
      };

      if (!Object.keys(params.VpcConfig).length || didVpcChange()) {
        delete params.VpcConfig;
      }
    }

    const executionRole = this.provider.getCustomExecutionRole(functionObj);
    if (executionRole) {
      params.Role = await this.normalizeArnRole(executionRole);
    }

    if (params.Role === remoteFunctionConfiguration.Role) {
      delete params.Role;
    }

    if (functionObj.image) {
      const imageConfig = {};
      if (_.isObject(functionObj.image)) {
        if (functionObj.image.command) {
          imageConfig.Command = functionObj.image.command;
        }
        if (functionObj.image.entryPoint) {
          imageConfig.EntryPoint = functionObj.image.entryPoint;
        }
        if (functionObj.image.workingDirectory) {
          imageConfig.WorkingDirectory = functionObj.image.workingDirectory;
        }
      }

      if (
        !_.isEqual(
          imageConfig,
          _.get(remoteFunctionConfiguration, 'ImageConfigResponse.ImageConfig', {})
        )
      ) {
        params.ImageConfig = imageConfig;
      }
    }

    if (!Object.keys(_.omit(params, 'FunctionName')).length) {
      if (this.options['update-config']) log.notice();
      const noticeMessage = [
        'Function configuration did not change, and the update was skipped.',
        ' If you made changes to the service configuration and expected them to be deployed,',
        ' it most likely means that they can only be applied with a full service deployment.',
      ].join('');
      log.notice.skip(
        `${noticeMessage} ${style.aside(
          `(${Math.floor(
            (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000
          )}s)`
        )}`
      );
      return;
    }

    mainProgress.notice('Updating function configuration', { isMainEvent: true });

    await this.callUpdateFunctionConfiguration(params);
    this.shouldEnsureFunctionState = true;
    if (this.options['update-config']) log.notice();
    log.notice.success(
      `Function configuration updated ${style.aside(
        `(${Math.floor((Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000)}s)`
      )}\n`
    );
  }

  async deployFunction() {
    const functionObject = this.serverless.service.getFunction(this.options.function);
    const params = {
      FunctionName: this.options.functionObj.name,
    };

    if (functionObject.image) {
      const { functionImageUri, functionImageSha } = await this.provider.resolveImageUriAndSha(
        this.options.function
      );
      const remoteImageSha =
        this.serverless.service.provider.remoteFunctionData.Configuration.CodeSha256;
      if (remoteImageSha === functionImageSha && !this.options.force) {
        log.notice();
        log.notice.skip(
          `Image did not change. Function deployment skipped. ${style.aside(
            `(${Math.floor(
              (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000
            )}s)`
          )}`
        );
        return;
      }
      params.ImageUri = functionImageUri;
    } else {
      const artifactFileName = this.provider.naming.getFunctionArtifactName(this.options.function);
      let artifactFilePath =
        this.serverless.service.package.artifact || path.join(this.packagePath, artifactFileName);
      // check if an artifact is used in function package level
      if (_.get(functionObject, 'package.artifact')) {
        artifactFilePath = functionObject.package.artifact;
      }

      const data = fs.readFileSync(artifactFilePath);

      const remoteHash =
        this.serverless.service.provider.remoteFunctionData.Configuration.CodeSha256;
      const localHash = crypto.createHash('sha256').update(data).digest('base64');

      if (remoteHash === localHash && !this.options.force) {
        log.notice();
        log.notice.skip(
          `Code did not change. Function deployment skipped. ${style.aside(
            `(${Math.floor(
              (Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000
            )}s)`
          )}`
        );
        return;
      }

      params.ZipFile = data;

      const stats = fs.statSync(artifactFilePath);
      mainProgress.notice(`Uploading ${style.aside(`(${filesize(stats.size)})`)}`, {
        isMainEvent: true,
      });
    }

    mainProgress.notice('Deploying', { isMainEvent: true });
    await this.provider.request('Lambda', 'updateFunctionCode', params);
    this.shouldEnsureFunctionState = true;
    log.notice();
    log.notice.success(
      `Function code deployed ${style.aside(
        `(${Math.floor((Date.now() - this.serverless.pluginManager.commandRunStartTime) / 1000)}s)`
      )}`
    );
  }
}

module.exports = AwsDeployFunction;
