# frozen_string_literal: true

require 'litany/cli/base'

require 'litany/project/project'
require 'litany/project/project_workspace'

module Litany
  module CLI
    # Abstract base class for CLI commands that work within a Litany project workspace.
    # @abstract
    class ProjectBase < Base

      option ['-d', '--directory'], 'PATH', 'project workspace directory', default: Dir.pwd, &proc { |dir| File.expand_path(dir) }
      option ['-e', '--environment'], 'ENV', 'environment to target, by default all environments are processed.', default: nil, multivalued: true
      option ['-s', '--stage'], 'STAGE', 'deployment stage to target, uses `default` if not explicitly set', default: nil, multivalued: true

      # @!group Delegate Methods

      # Asks the cli user for an MFA token and returns it.
      # @return [String]
      def mfa_token
        ask 'Multi-factor auth detected. What is your current MFA token?'
      end

      # @!endgroup

      # @!group Iterators and Enumerators

      # Returns an {Enumerator} which creates an {Project} instance for each member of #run_list and selects compatible stacks from it.  These instances are not cached, and are created as they are pulled off the Enumerator.
      # @return [Enumerator<Project>]
      private def matching_stacks
        raise 'This CLI command must have a `stack_list` option defined to use this helper.' unless respond_to?(:stack_list)
        target_stacks = stack_list.collect(&:downcase)

        Enumerator.new do |yield_to|
          projects.each do |project|
            project.compile

            matching_stacks = project.compiled_stacks.keys
            matching_stacks.select! { |name| target_stacks.include?(name.to_s.downcase) } unless target_stacks.empty?

            next warn "No matching stacks found for Stage: #{project.stage}, Environment: #{project.active_environment.name}, Target Stacks: #{stack_list.join(', ')}" if matching_stacks.empty?
            warn "Did not find all the stacks in the list. Targeted: #{stack_list.join(', ')}; Found: #{matching_stacks.join(', ')}" unless matching_stacks.length == target_stacks.length || target_stacks.empty?

            matching_stacks.sort.each { |stack_name| yield_to << [project, stack_name] }
          end
        end
      end

      # Returns an {Enumerator} which creates an {Project} instance for each member of #run_list.  These instances are not cached, and are created as they are pulled off the Enumerator.
      # @return [Enumerator<Project>]
      private def projects
        Enumerator.new do |yield_to|
          run_list.each do |workspace, environment_name|
            yield_to << workspace.project(environment: environment_name)
          end
        end
      end

      # Generates a full list of each run to perform.  Based on the values of the `--environment` and `--stage` CLI options.
      # @return [Array<ProjectWorkspace, String>] a list of tuples for each run requested as [Stage, Environment].
      private def run_list
        return @run_list if instance_variable_defined?(:@run_list)

        stages = stage_list || ProjectWorkspace.new(directory: directory, delegate: self)['project.allowed_stages']
        workspaces = stages.map { |stage| ProjectWorkspace.new(directory: directory, stage: stage, delegate: self) }
        environments = environment_list || workspaces.flat_map { |workspace| workspace.project.environments.collect(&:name) }.to_set.to_a

        @run_list = workspaces.product(environments)
      end

      # @!endgroup

      # @!group Change Set Management

      # Caches and returns a colorized version of an important message from a change set.
      # @param text [String] the text to colorize
      # @return [String]
      private def colorize_for_change(text)
        @replacement_colors ||= {
          Add: :green,
          Always: :red,
          Conditional: :yellow,
          Conditionally: :yellow,
          False: :green,
          Modify: :green,
          Never: :green,
          Remove: :red,
          True: :red
        }

        @colorized ||= {}
        @colorized[text] ||= color(text, @replacement_colors[text.to_sym], :bold)
      end

      # Prints a bannerless change from a given change set to the terminal.
      # @param change [Struct]
      # @return [void]
      private def display_change(change)
        say "\n"
        say "#{colorize_for_change(change.action)}: #{change.logical_resource_id}".indent(1)
        say "- Physical ID: #{change.physical_resource_id}".indent(2) unless change.physical_resource_id.nil? || change.physical_resource_id == change.logical_resource_id
        say "- Type: #{change.resource_type}".indent(2)
        say "- Replacement Required: #{colorize_for_change(change.replacement)}".indent(2) unless change.replacement.nil?
        say "- Scope: #{change.scope.join(', ')}".indent(2) unless change.scope.empty?

        return if change.details.empty?

        say '- Details:'.indent(2)
        change.details.each do |detail|
          say "Target #{detail.target.attribute.singularize} Name: #{detail.target.name}".indent(3)
          say "- Requires Recreation: #{colorize_for_change(detail.target.requires_recreation)}".indent(3) unless detail.target.requires_recreation.nil?
          say "- Change Source: #{detail.change_source}".indent(3)
          say "- Causing Entity: #{detail.causing_entity}".indent(3) unless detail.causing_entity.nil?
          say "- Evaluation: #{detail.evaluation}".indent(3)
        end
      end

      # Submits, parses, and displays a change set.
      # @param project [Project] The project that the stack to validate belongs to.
      # @param stack_name [Symbol] The internal name of the stack to validate.
      # @param auto_delete: [Boolean] If true, the change set will be scheduled to be deleted `at_exit`
      # @return [String, nil] Returns the name of the change set if this method is successful, returns nil if the change set creation was unsuccessful.
      private def submit_change_set(project, stack_name, auto_delete:)
        return nil unless can_upload_stack?(project, stack_name)

        cfn_client = project.cfn_client
        change_set_name = "litany-#{stack_name}-#{Time.now.utc.to_i}"[0..127]
        info "Submitting change set. Name: #{change_set_name}, Stage: #{project.stage}, Environment: #{project.active_environment.name}"

        unless stack_exist?(project, stack_name)
          info 'Cannot submit change set as it does not exist yet.'.indent(1)
          return nil
        end

        output = project.output(stack_name)
        cfn_client.create_change_set(stack_name: stack_name, change_set_name: change_set_name, template_body: output, capabilities: ['CAPABILITY_NAMED_IAM'])
        change_set = cfn_client.describe_change_set(stack_name: stack_name, change_set_name: change_set_name)

        if change_set.status.casecmp?('failed') && change_set.status_reason.include?("The submitted information didn't contain changes.")
          info 'No pending changes detected.'.indent(1)
          return nil
        end

        begin
          cfn_client.wait_until(:change_set_create_complete, stack_name: stack_name, change_set_name: change_set_name) { |waiter| waiter.delay = 3 }
        rescue Aws::Waiters::Errors::FailureStateError, Aws::Waiters::Errors::UnexpectedError
          warn 'Unable to process the change state.'.indent(1)
          cfn_client.delete_change_set(stack_name: stack_name, change_set_name: change_set_name)
          return nil
        end

        at_exit { cfn_client.delete_change_set(stack_name: stack_name, change_set_name: change_set_name) } if auto_delete
        change_set = cfn_client.describe_change_set(stack_name: stack_name, change_set_name: change_set_name)

        if change_set.changes.length.positive?
          info "Detected #{change_set.changes.length} change(s)".indent(1)
          change_set.changes.each { |change| display_change(change.resource_change) }
          say "\n"
        else
          info 'Change set was created but had no changes in it.'.indent(1)
        end

        change_set_name
      end

      # @!endgroup

      # @!group Stack Helpers

      # Tests to see if a stack is currently in a state in AWS that allows us to upload to it.
      # @param project [Project] the project which the stack belongs to
      # @param stack_name [Symbol] internal name of the stack which is being changed
      # @return [Boolean]
      def can_upload_stack?(project, stack_name)
        output = project.output(stack_name)
        if output.nil?
          warn "Unable to upload as output does not exist for Stack: #{stack_name}, Stage: #{project.stage}, Environment: #{project.active_environment.name}".indent(1)
          return false
        end

        if output.length > 51_000
          # TODO: replace this with with the eventual s3 upload path
          warn "Unable to upload #{stack_name} as the output is too large (#{output.length} characters)!".indent(1)
          return false
        end

        statuses = project.cfn_client.describe_stacks(stack_name: stack_name).stacks.collect(&:stack_status)

        if statuses.length > 1
          error "Two stacks detected with the same name, this shouldn't happen with Litany.".indent(1)
          return false
        end

        !statuses[0].end_with?('PROGRESS')
      rescue Aws::CloudFormation::Errors::ValidationError => e
        e.message.include?('does not exist')
      end

      # Tests to see if a stack exists in AWS, unfortunately there's not a great way to do this...
      # @param project [Project] the project which the stack belongs to
      # @param stack_name [Symbol] internal name of the stack which is being changed
      # @return [Boolean]
      def stack_exist?(project, stack_name)
        project.cfn_client.describe_stacks(stack_name: stack_name)
        true
      rescue Aws::CloudFormation::Errors::ValidationError => e
        unless e.message.include?('does not exist')
          error 'Unexpected response while testing for stack existence.'
          error e.message.indent(1)
        end

        false
      end

      # @!endgroup
    end
  end
end
