# frozen_string_literal: true

require 'aws-sdk-s3'
require 'aws-sdk-kms'
require 'base64'
require 'openssl'

require 'arcana/mapping'

# Arcana is a straight forward secret management solution built on top of widely avaiable AWS Services.  It's completely
#   isolated and as secure as it's upstream AWS services.
module Arcana

  class InvalidMaterial < RuntimeError
  end
  class InvalidIntermediateKey < RuntimeError
  end
  class NoMaterial < RuntimeError
  end

  # A client for interacting with Arcana secrets stored in S3
  class Client
    # Returns the name of the bucket which the key should be stored in
    # @return [String]
    attr_reader :bucket

    # Returns the kms client for this Arcana client
    # @return [Aws::KMS::Client]
    attr_reader :kms_client

    # Returns the s3 client for this Arcana client
    # @return [Aws::S3::Client]
    attr_reader :s3_client

    # Return the service id for the service who owns this secret
    # @return [String]
    attr_reader :service_id

    # Return the KMS key name for the service's master key
    # @return [String]
    attr_reader :service_key

    # @param bucket: [String] name of the bucket the secret should be stored in.
    # @param service_id: [String] the service id the secret belongs to.  In Litany this will look like
    #   '<service name>-<stage name>-<project name>-<environment name>'.
    # @param system_prefix: [String] The system prefix for this secret.  This is generally related to the library that's
    #   leveraging Arcana.  In Litany these look like 'litany/<environment name>'
    # @param aws_profile: [String] optional, used if you need to reference a specific profile inside of your ~/.aws/credentials file.
    #   this is not compatible with providing aws_credentials.
    # @param aws_credentials: [Aws::CredentialProvider] optional, a valid credential provider to use for authentication.
    #   this is not compatible with providing a aws_profile.
    # @param aws_region: [String] optional, which AWS region should Arcana connect to for this secret. You can pass in either the full
    #   region id ('us-west-2') or one of the standard abbreviations ('pdx'). If omitted, the value is attempted to be inferred from the
    #   service_id.  This will only work for service-ids formatted similarly to Litany's where the string after the final '-' is a known
    #   short region code.
    def initialize(bucket:, service_id:, system_prefix:, aws_profile: nil, aws_credentials: nil, aws_region: nil)
      raise 'You may not provide both `aws_profile` and `aws_credentials`.' unless aws_credentials.nil? || aws_profile.nil?

      config = {
        region: Arcana.region_id(aws_region, service_id)
      }

      config[:credentials] = aws_credentials unless aws_credentials.nil?
      config[:profile] = aws_profile unless aws_profile.nil?

      @kms_client = Aws::KMS::Client.new(config)
      @s3_client = Aws::S3::Client.new(config)

      @service_key = "alias/#{system_prefix}/#{service_id}"
      @service_id = service_id
      @bucket = bucket
    end

    # Encrypts a chunk of data and stores it in S3.
    # @param material_name [String] the name of this particular secret
    # @param data [String] the data to encrypt
    # @return [Boolean]
    def encrypt(material_name, data:)
      intermediate_key = kms_client.generate_data_key(
        key_id: service_key,
        key_spec: 'AES_256',
        encryption_context: encryption_context(material_name)
      )

      aes = OpenSSL::Cipher.new('aes-256-cbc')
      aes.encrypt

      aes.key = intermediate_key[:plaintext]
      aes.iv = aes_iv = OpenSSL::Cipher.new('aes-256-cbc').random_iv

      s3_client.put_object(
        bucket: bucket,
        key: "#{service_id}/#{material_name}",
        body: Base64.strict_encode64(aes.update(data) << aes.final),
        metadata: {
          'arcana-iv' => Base64.strict_encode64(aes_iv),
          'arcana-key' => Base64.strict_encode64(intermediate_key[:ciphertext_blob])
        }
      )

      # Attempting to flush these sensitive variables from memory
      intermediate_key, aes, data, aes_iv = nil # rubocop:disable Lint/UselessAssignment
      GC.start

      true
    end

    # Decrypts a secret from AWS and returns it's contents.
    # @param material_name [String] the name of the particular secret
    # @return [String]
    def decrypt(material_name)
      begin
        material = s3_client.get_object(
          bucket: bucket,
          key: "#{service_id}/#{material_name}"
        )
      rescue Aws::S3::Errors::NoSuchKey
        throw NoMaterial.new("Material `#{material_name}` does not exist.")
      end

      material_body = material[:body].read

      throw InvalidMaterial.new("Material `#{material_name}` does not have needed metadata.") if material[:metadata].nil?
      throw InvalidMaterial.new("Material `#{material_name}` does not have a body.") if material_body.nil?

      intermediate_key = kms_client.decrypt(
        ciphertext_blob: Base64.strict_decode64(material[:metadata]['arcana-key']),
        encryption_context: encryption_context(material_name)
      )

      throw InvalidIntermediateKey.new("Material `#{material_name}` does not have a intermediate key.") if intermediate_key[:plaintext].nil?

      aes = OpenSSL::Cipher.new('aes-256-cbc')
      aes.decrypt

      aes.key = intermediate_key[:plaintext]
      aes.iv = Base64.strict_decode64(material[:metadata]['arcana-iv'])

      data = aes.update(Base64.strict_decode64(material_body)) << aes.final

      # Attempting to flush these sensitive variables from memory
      intermediate_key, material, aes = nil # rubocop:disable Lint/UselessAssignment
      GC.start

      data
    end

    # Decrypts a secret from AWS and returns it's contents without any beginning or ending whitespace.
    # @param material_name [String] the name of the particular secret
    # @return [String]
    def decrypt_and_strip(material_name)
      decrypt(material_name).strip
    end

    # Returns the encryption context for a given material, this is part of our logging mechanism in cloud trail.
    # @param material_name [String] the name of the particular secret
    # @return [String]
    private def encryption_context(name)
      {
        secret_name: name,
        service_id: service_id,
        s3_bucket: bucket,
        s3_key: "#{service_id}/#{name}"
      }
    end
  end
end