# frozen_string_literal: true

module Dpl
  module Providers
    class Elasticbeanstalk < Provider
      register :elasticbeanstalk

      status :stable

      full_name 'AWS Elastic Beanstalk'

      description sq(<<-STR)
        Deploy to AWS Elastic Beanstalk: https://aws.amazon.com/elasticbeanstalk/

        This provider:

        * Creates a zip file (or uses one you provide)
        * Uploads it to your EB application
        * Optionally deploys to a specific EB environment
        * Optionally waits until the deployment finishes
      STR

      gem 'nokogiri', '~> 1.15'
      gem 'aws-sdk-elasticbeanstalk', '~> 1'
      gem 'aws-sdk-s3', '~> 1'
      gem 'rubyzip', '~> 2.3', require: 'zip'
      gem 'pathspec', '~> 1.1', require: 'pathspec'

      env :aws, :elastic_beanstalk
      config '~/.aws/credentials', '~/.aws/config', prefix: 'aws'

      opt '--access_key_id ID', 'AWS Access Key ID', required: true, secret: true
      opt '--secret_access_key KEY', 'AWS Secret Key', required: true, secret: true
      opt '--region REGION', 'AWS Region the Elastic Beanstalk app is running in', default: 'us-east-1'
      opt '--app NAME', 'Elastic Beanstalk application name', default: :repo_name
      opt '--env NAME', 'Elastic Beanstalk environment name to be updated.'
      opt '--bucket NAME', 'Bucket name to upload app to', required: true, alias: :bucket_name
      opt '--bucket_path PATH', 'Location within Bucket to upload app to'
      opt '--description DESC', 'Description for the application version'
      opt '--label LABEL', 'Label for the application version'
      opt '--zip_file PATH', 'The zip file that you want to deploy. If not given, a zipfile will be created from the current directory, honoring .ebignore and .gitignore.'
      opt '--wait_until_deployed', 'Wait until the deployment has finished', requires: :env
      opt '--wait_until_deployed_timeout SEC', 'How many seconds to wait for Elastic Beanstalk deployment update.', type: :integer, default: 600
      opt '--debug', internal: true

      msgs login: 'Using Access Key: %{access_key_id}',
           zip_add: 'Adding %s'

      msgs clean_description: 'Removed non-printable characters from the version description'

      attr_reader :started, :object, :version

      def login
        info :login
      end

      def setup
        info :login
        Aws.config.update(credentials:, region:)
      end

      def deploy
        @started = Time.now
        bucket.create unless bucket.exists?
        create_zip unless zip_exists?
        upload
        create_version
        update_app if env?
      end

      def zip_file
        zip_file? ? expand(super) : archive_name
      end

      def archive_name
        "#{label}.zip"
      end

      def label
        @label ||= super || "travis-#{git_sha}-#{Time.now.to_i}"
      end

      def description
        super || git_commit_msg
      end

      def bucket_path
        bucket_path? ? "#{super.gsub(%r{/*$}, '')}/#{archive_name}" : archive_name
      end

      def cwd
        @cwd ||= "#{Dir.pwd}/"
      end

      def zip_exists?
        File.exist?(zip_file)
      end

      def create_zip
        ::Zip::File.open(zip_file, ::Zip::File::CREATE) do |zip|
          files.each do |path|
            debug :zip_add, path
            zip.add(path.sub(cwd, ''), path)
          end
        end
      end

      def upload
        @object = bucket.object(bucket_path)
        object.put(body: File.open(zip_file))
        sleep 5 # s3 eventual consistency
      end

      def create_version
        @version = eb.create_application_version(
          application_name: app,
          version_label: label,
          description: clean(description[0, 200]),
          source_bundle: {
            s3_bucket: bucket.name,
            s3_key: object.key
          },
          auto_create_application: false
        )
      end

      def update_app
        eb.update_environment(
          environment_name: env,
          version_label: version[:application_version][:version_label]
        )
        wait_until_deployed if wait_until_deployed?
      end

      def wait_until_deployed
        msgs = []
        1.upto(wait_until_deployed_timeout / 5) { return if check_deployment(msgs) }
        error 'Deploy status unknown due to timeout. Increase the wait_until_deployed_timeout option'
      end

      def check_deployment(msgs)
        sleep 5
        events.each do |event|
          msg = "#{event.event_date} [#{event.severity}] #{event.message}"
          error "Deployment failed: #{msg}" if event.severity == 'ERROR'
          info msg unless msgs.include?(msg)
          msgs << msg
        end
        environment[:status] == 'Ready'
      rescue Aws::Errors::ServiceError => e
        info "Caught #{e}: #{e.message}. Retrying ..."
      end

      def files
        files = Dir.glob('**/*', File::FNM_DOTMATCH)
        ignore = %w[.ebignore .gitignore].detect { |file| file?(file) }
        files = filter(files, ignore) if ignore
        files
      end

      def filter(files, spec)
        spec = PathSpec.from_filename(spec)
        files.reject { |file| spec.match(file) }
      end

      def events
        args = { environment_name: env, start_time: started.utc.iso8601 }
        eb.describe_events(args)[:events].reverse
      end

      def environment
        args = { application_name: app, environment_names: [env] }
        eb.describe_environments(args)[:environments].first
      end

      def credentials
        Aws::Credentials.new(access_key_id, secret_access_key)
      end

      def s3
        @s3 ||= Aws::S3::Resource.new
      end

      def bucket
        @bucket ||= s3.bucket(super)
      end

      def eb
        @eb ||= Aws::ElasticBeanstalk::Client.new(retry_limit: 10)
      end

      # We do not actually know what characters are valid on AWS EB's side,
      # see: https://github.com/aws/aws-sdk-ruby/issues/1502
      #
      # Reference: https://www.w3.org/TR/xml/#charsets
      NON_PRINTABLE_CHARS = "\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF"

      def clean(str)
        str.gsub!(/[^#{NON_PRINTABLE_CHARS}]/, '') && info(:clean_description)
        str
      end

      def debug(*args)
        info(*args) if debug?
      end
    end
  end
end
