'use strict';

const AWS = require('aws-sdk');
const BbPromise = require('bluebird');
const crypto = require('crypto');
const fs = require('fs');
const _ = require('lodash');
const path = require('path');

class AwsCompileFunctions {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;
    const servicePath = this.serverless.config.servicePath || '';
    this.packagePath =
      this.serverless.service.package.path || path.join(servicePath || '.', '.serverless');

    this.provider = this.serverless.getProvider('aws');

    if (
      this.serverless.service.provider.versionFunctions === undefined ||
      this.serverless.service.provider.versionFunctions === null
    ) {
      this.serverless.service.provider.versionFunctions = true;
    }

    this.hooks = {
      'package:compileFunctions': () =>
        BbPromise.bind(this)
          .then(this.downloadPackageArtifacts)
          .then(this.compileFunctions),
    };
  }

  compileRole(newFunction, role) {
    const compiledFunction = newFunction;
    const unsupportedRoleError = new this.serverless.classes.Error(
      `Unsupported role provided: "${JSON.stringify(role)}"`
    );

    switch (typeof role) {
      case 'object':
        if ('Fn::GetAtt' in role) {
          // role is an "Fn::GetAtt" object
          compiledFunction.Properties.Role = role;
          compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(
            role['Fn::GetAtt'][0]
          );
        } else if ('Fn::ImportValue' in role) {
          // role is an "Fn::ImportValue" object
          compiledFunction.Properties.Role = role;
        } else {
          throw unsupportedRoleError;
        }
        break;
      case 'string':
        if (role.startsWith('arn:aws')) {
          // role is a statically defined iam arn
          compiledFunction.Properties.Role = role;
        } else if (role === 'IamRoleLambdaExecution') {
          // role is the default role generated by the framework
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
          compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(
            'IamRoleLambdaExecution'
          );
        } else {
          // role is a Logical Role Name
          compiledFunction.Properties.Role = { 'Fn::GetAtt': [role, 'Arn'] };
          compiledFunction.DependsOn = (compiledFunction.DependsOn || []).concat(role);
        }
        break;
      default:
        throw unsupportedRoleError;
    }
  }

  downloadPackageArtifact(functionName) {
    const { region } = this.options;
    const S3 = new AWS.S3({ region });

    const functionObject = this.serverless.service.getFunction(functionName);
    const artifactFilePath =
      _.get(functionObject, 'package.artifact') ||
      _.get(this, 'serverless.service.package.artifact');

    const regex = new RegExp('s3\\.amazonaws\\.com/(.+)/(.+)');
    const match = artifactFilePath.match(regex);

    if (match) {
      return new BbPromise((resolve, reject) => {
        const tmpDir = this.serverless.utils.getTmpDirPath();
        const filePath = path.join(tmpDir, match[2]);

        const readStream = S3.getObject({
          Bucket: match[1],
          Key: match[2],
        }).createReadStream();

        const writeStream = fs.createWriteStream(filePath);

        readStream.on('error', error => reject(error));
        readStream
          .pipe(writeStream)
          .on('error', reject)
          .on('close', () => {
            if (functionObject.package.artifact) {
              functionObject.package.artifact = filePath;
            } else {
              this.serverless.service.package.artifact = filePath;
            }
            return resolve(filePath);
          });
      });
    }

    return BbPromise.resolve();
  }

  compileFunction(functionName) {
    const newFunction = this.cfLambdaFunctionTemplate();
    const functionObject = this.serverless.service.getFunction(functionName);
    functionObject.package = functionObject.package || {};

    const serviceArtifactFileName = this.provider.naming.getServiceArtifactName();
    const functionArtifactFileName = this.provider.naming.getFunctionArtifactName(functionName);

    let artifactFilePath =
      functionObject.package.artifact || this.serverless.service.package.artifact;

    if (
      !artifactFilePath ||
      (this.serverless.service.artifact && !functionObject.package.artifact)
    ) {
      let artifactFileName = serviceArtifactFileName;
      if (this.serverless.service.package.individually || functionObject.package.individually) {
        artifactFileName = functionArtifactFileName;
      }

      artifactFilePath = path.join(
        this.serverless.config.servicePath,
        '.serverless',
        artifactFileName
      );
    }

    if (this.serverless.service.package.deploymentBucket) {
      newFunction.Properties.Code.S3Bucket = this.serverless.service.package.deploymentBucket;
    }

    const s3Folder = this.serverless.service.package.artifactDirectoryName;
    const s3FileName = artifactFilePath.split(path.sep).pop();
    newFunction.Properties.Code.S3Key = `${s3Folder}/${s3FileName}`;

    if (!functionObject.handler) {
      const errorMessage = [
        `Missing "handler" property in function "${functionName}".`,
        ' Please make sure you point to the correct lambda handler.',
        ' For example: handler.hello.',
        ' Please check the docs for more info',
      ].join('');
      return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
    }

    const Handler = functionObject.handler;
    const FunctionName = functionObject.name;
    const MemorySize =
      Number(functionObject.memorySize) ||
      Number(this.serverless.service.provider.memorySize) ||
      1024;
    const Timeout =
      Number(functionObject.timeout) || Number(this.serverless.service.provider.timeout) || 6;
    const Runtime =
      functionObject.runtime || this.serverless.service.provider.runtime || 'nodejs12.x';

    newFunction.Properties.Handler = Handler;
    newFunction.Properties.FunctionName = FunctionName;
    newFunction.Properties.MemorySize = MemorySize;
    newFunction.Properties.Timeout = Timeout;
    newFunction.Properties.Runtime = Runtime;

    // publish these properties to the platform
    this.serverless.service.functions[functionName].memory = MemorySize;
    this.serverless.service.functions[functionName].timeout = Timeout;
    this.serverless.service.functions[functionName].runtime = Runtime;

    if (functionObject.description) {
      newFunction.Properties.Description = functionObject.description;
    }

    if (functionObject.condition) {
      newFunction.Condition = functionObject.condition;
    }

    if (functionObject.dependsOn) {
      newFunction.DependsOn = (newFunction.DependsOn || []).concat(functionObject.dependsOn);
    }

    if (functionObject.tags || this.serverless.service.provider.tags) {
      const tags = Object.assign({}, this.serverless.service.provider.tags, functionObject.tags);
      newFunction.Properties.Tags = [];
      _.forEach(tags, (Value, Key) => {
        newFunction.Properties.Tags.push({ Key, Value });
      });
    }

    if (functionObject.onError) {
      const arn = functionObject.onError;

      if (typeof arn === 'string') {
        const splittedArn = arn.split(':');
        if (splittedArn[0] === 'arn' && (splittedArn[2] === 'sns' || splittedArn[2] === 'sqs')) {
          const dlqType = splittedArn[2];
          const iamRoleLambdaExecution = this.serverless.service.provider
            .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;
          let stmt;

          newFunction.Properties.DeadLetterConfig = {
            TargetArn: arn,
          };

          if (dlqType === 'sns') {
            stmt = {
              Effect: 'Allow',
              Action: ['sns:Publish'],
              Resource: [arn],
            };
          } else if (dlqType === 'sqs') {
            const errorMessage = [
              'onError currently only supports SNS topic arns due to a',
              ' race condition when using SQS queue arns and updating the IAM role.',
              ' Please check the docs for more info.',
            ].join('');
            return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
          }

          // update the PolicyDocument statements (if default policy is used)
          if (iamRoleLambdaExecution) {
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push(stmt);
          }
        } else {
          const errorMessage = 'onError config must be a SNS topic arn or SQS queue arn';
          return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
        }
      } else if (this.isArnRefGetAttOrImportValue(arn)) {
        newFunction.Properties.DeadLetterConfig = {
          TargetArn: arn,
        };
      } else {
        const errorMessage = [
          'onError config must be provided as an arn string,',
          ' Ref, Fn::GetAtt or Fn::ImportValue',
        ].join('');
        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    let kmsKeyArn;
    const serviceObj = this.serverless.service.serviceObject;
    if ('awsKmsKeyArn' in functionObject) {
      kmsKeyArn = functionObject.awsKmsKeyArn;
    } else if (serviceObj && 'awsKmsKeyArn' in serviceObj) {
      kmsKeyArn = serviceObj.awsKmsKeyArn;
    }

    if (kmsKeyArn) {
      const arn = kmsKeyArn;

      if (typeof arn === 'string') {
        const splittedArn = arn.split(':');
        if (splittedArn[0] === 'arn' && splittedArn[2] === 'kms') {
          const iamRoleLambdaExecution = this.serverless.service.provider
            .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;

          newFunction.Properties.KmsKeyArn = arn;

          const stmt = {
            Effect: 'Allow',
            Action: ['kms:Decrypt'],
            Resource: [arn],
          };

          // update the PolicyDocument statements (if default policy is used)
          if (iamRoleLambdaExecution) {
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
              iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
              [stmt],
              _.isEqual
            );
          }
        } else {
          const errorMessage = 'awsKmsKeyArn config must be a KMS key arn';
          return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
        }
      } else {
        const errorMessage = 'awsKmsKeyArn config must be provided as a string';
        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    const tracing =
      functionObject.tracing ||
      (this.serverless.service.provider.tracing && this.serverless.service.provider.tracing.lambda);

    if (tracing) {
      if (typeof tracing === 'boolean' || typeof tracing === 'string') {
        let mode = tracing;

        if (typeof tracing === 'boolean') {
          mode = 'Active';
        }

        const iamRoleLambdaExecution = this.serverless.service.provider
          .compiledCloudFormationTemplate.Resources.IamRoleLambdaExecution;

        newFunction.Properties.TracingConfig = {
          Mode: mode,
        };

        const stmt = {
          Effect: 'Allow',
          Action: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'],
          Resource: ['*'],
        };

        // update the PolicyDocument statements (if default policy is used)
        if (iamRoleLambdaExecution) {
          iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement = _.unionWith(
            iamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement,
            [stmt],
            _.isEqual
          );
        }
      } else {
        const errorMessage = 'tracing requires a boolean value or the "mode" provided as a string';
        throw new this.serverless.classes.Error(errorMessage);
      }
    }

    if (functionObject.environment || this.serverless.service.provider.environment) {
      newFunction.Properties.Environment = {};
      newFunction.Properties.Environment.Variables = Object.assign(
        {},
        this.serverless.service.provider.environment,
        functionObject.environment
      );

      let invalidEnvVar = null;
      _.forEach(_.keys(newFunction.Properties.Environment.Variables), key => {
        // taken from the bash man pages
        if (!key.match(/^[A-Za-z_][a-zA-Z0-9_]*$/)) {
          invalidEnvVar = `Invalid characters in environment variable ${key}`;
          return false; // break loop with lodash
        }
        const value = newFunction.Properties.Environment.Variables[key];
        if (_.isObject(value)) {
          const isCFRef =
            _.isObject(value) && !_.some(value, (v, k) => k !== 'Ref' && !_.startsWith(k, 'Fn::'));
          if (!isCFRef) {
            invalidEnvVar = `Environment variable ${key} must contain string`;
            return false;
          }
        }
        return true;
      });

      if (invalidEnvVar) {
        return BbPromise.reject(new this.serverless.classes.Error(invalidEnvVar));
      }
    }

    if ('role' in functionObject) {
      this.compileRole(newFunction, functionObject.role);
    } else if ('role' in this.serverless.service.provider) {
      this.compileRole(newFunction, this.serverless.service.provider.role);
    } else {
      this.compileRole(newFunction, 'IamRoleLambdaExecution');
    }

    if (!functionObject.vpc) functionObject.vpc = {};
    if (!this.serverless.service.provider.vpc) this.serverless.service.provider.vpc = {};

    newFunction.Properties.VpcConfig = {
      SecurityGroupIds:
        functionObject.vpc.securityGroupIds ||
        this.serverless.service.provider.vpc.securityGroupIds,
      SubnetIds: functionObject.vpc.subnetIds || this.serverless.service.provider.vpc.subnetIds,
    };

    if (
      !newFunction.Properties.VpcConfig.SecurityGroupIds ||
      !newFunction.Properties.VpcConfig.SubnetIds
    ) {
      delete newFunction.Properties.VpcConfig;
    }

    if (functionObject.reservedConcurrency || functionObject.reservedConcurrency === 0) {
      // Try convert reservedConcurrency to integer
      const reservedConcurrency = _.parseInt(functionObject.reservedConcurrency);

      if (_.isInteger(reservedConcurrency)) {
        newFunction.Properties.ReservedConcurrentExecutions = reservedConcurrency;
      } else {
        const errorMessage = [
          'You should use integer as reservedConcurrency value on function: ',
          `${newFunction.Properties.FunctionName}`,
        ].join('');

        return BbPromise.reject(new this.serverless.classes.Error(errorMessage));
      }
    }

    const versionFunction =
      functionObject.versionFunction != null
        ? functionObject.versionFunction
        : this.serverless.service.provider.versionFunctions;

    if (functionObject.provisionedConcurrency && !versionFunction) {
      return BbPromise.reject(
        new this.serverless.classes.Error(
          'Cannot setup provisioned conncurrency with lambda versioning disabled on function: ' +
            `${newFunction.Properties.FunctionName}`
        )
      );
    }

    newFunction.DependsOn = [this.provider.naming.getLogGroupLogicalId(functionName)].concat(
      newFunction.DependsOn || []
    );

    if (functionObject.layers && _.isArray(functionObject.layers)) {
      newFunction.Properties.Layers = functionObject.layers;
    } else if (
      this.serverless.service.provider.layers &&
      _.isArray(this.serverless.service.provider.layers)
    ) {
      newFunction.Properties.Layers = this.serverless.service.provider.layers;
    }

    const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName);
    const newFunctionObject = {
      [functionLogicalId]: newFunction,
    };

    _.merge(
      this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
      newFunctionObject
    );

    // Create hashes for the artifact and the logical id of the version resource
    // The one for the version resource must include the function configuration
    // to make sure that a new version is created on configuration changes and
    // not only on source changes.
    const fileHash = crypto.createHash('sha256');
    const versionHash = crypto.createHash('sha256');
    fileHash.setEncoding('base64');
    versionHash.setEncoding('base64');

    // Read the file in chunks and add them to the hash (saves memory and performance)
    return BbPromise.fromCallback(cb => {
      const readStream = fs.createReadStream(artifactFilePath);

      readStream
        .on('data', chunk => {
          fileHash.write(chunk);
          versionHash.write(chunk);
        })
        .on('close', () => {
          cb();
        })
        .on('error', error => {
          cb(error);
        });
    }).then(() => {
      // Include function configuration in version id hash (without the Code part)
      const properties = _.omit(_.get(newFunction, 'Properties', {}), 'Code');
      _.forOwn(properties, value => {
        const hashedValue = _.isObject(value) ? JSON.stringify(value) : _.toString(value);
        versionHash.write(hashedValue);
      });

      // Finalize hashes
      fileHash.end();
      versionHash.end();

      const fileDigest = fileHash.read();
      const versionDigest = versionHash.read();

      if (!versionFunction) return;

      const newVersion = this.cfLambdaVersionTemplate();

      newVersion.Properties.CodeSha256 = fileDigest;
      newVersion.Properties.FunctionName = { Ref: functionLogicalId };
      if (functionObject.description) {
        newVersion.Properties.Description = functionObject.description;
      }

      if (functionObject.provisionedConcurrency) {
        const provisionedConcurrency = _.parseInt(functionObject.provisionedConcurrency);

        if (_.isInteger(provisionedConcurrency)) {
          newVersion.Properties.ProvisionedConcurrencyConfig = {
            ProvisionedConcurrentExecutions: provisionedConcurrency,
          };
        } else {
          throw new this.serverless.classes.Error(
            'You should use integer as provisionedConcurrency value on function: ' +
              `${newFunction.Properties.FunctionName}`
          );
        }
      }

      // use the version SHA in the logical resource ID of the version because
      // AWS::Lambda::Version resource will not support updates
      const versionLogicalId = this.provider.naming.getLambdaVersionLogicalId(
        functionName,
        versionDigest
      );
      const newVersionObject = {
        [versionLogicalId]: newVersion,
      };

      _.merge(
        this.serverless.service.provider.compiledCloudFormationTemplate.Resources,
        newVersionObject
      );

      // Add function versions to Outputs section
      const functionVersionOutputLogicalId = this.provider.naming.getLambdaVersionOutputLogicalId(
        functionName
      );
      const newVersionOutput = this.cfOutputLatestVersionTemplate();

      newVersionOutput.Value = { Ref: versionLogicalId };

      _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Outputs, {
        [functionVersionOutputLogicalId]: newVersionOutput,
      });
    });
  }

  downloadPackageArtifacts() {
    const allFunctions = this.serverless.service.getAllFunctions();
    return BbPromise.each(allFunctions, functionName => this.downloadPackageArtifact(functionName));
  }

  compileFunctions() {
    const allFunctions = this.serverless.service.getAllFunctions();
    return BbPromise.each(allFunctions, functionName => this.compileFunction(functionName));
  }

  // helper functions
  isArnRefGetAttOrImportValue(arn) {
    return (
      typeof arn === 'object' &&
      _.some(_.keys(arn), k => _.includes(['Ref', 'Fn::GetAtt', 'Fn::ImportValue'], k))
    );
  }

  cfLambdaFunctionTemplate() {
    return {
      Type: 'AWS::Lambda::Function',
      Properties: {
        Code: {
          S3Bucket: {
            Ref: 'ServerlessDeploymentBucket',
          },
          S3Key: 'S3Key',
        },
        FunctionName: 'FunctionName',
        Handler: 'Handler',
        MemorySize: 'MemorySize',
        Role: 'Role',
        Runtime: 'Runtime',
        Timeout: 'Timeout',
      },
    };
  }

  cfLambdaVersionTemplate() {
    return {
      Type: 'AWS::Lambda::Version',
      // Retain old versions even though they will not be in future
      // CloudFormation stacks. On stack delete, these will be removed when
      // their associated function is removed.
      DeletionPolicy: 'Retain',
      Properties: {
        FunctionName: 'FunctionName',
        CodeSha256: 'CodeSha256',
      },
    };
  }

  cfOutputLatestVersionTemplate() {
    return {
      Description: 'Current Lambda function version',
      Value: 'Value',
    };
  }
}

module.exports = AwsCompileFunctions;
