# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require_relative "../constants"
require_relative "../errors"
require_relative "../helpers"

module VagrantPlugins
  module Ansible
    module Provisioner

      # This class is a base class where the common functionality shared between
      # both Ansible provisioners are stored.
      # This is **not an actual provisioner**.
      # Instead, {Host} (ansible) or {Guest} (ansible_local) should be used.

      class Base < Vagrant.plugin("2", :provisioner)

        RANGE_PATTERN = %r{(?:\[[a-z]:[a-z]\]|\[[0-9]+?:[0-9]+?\])}.freeze

        ANSIBLE_PARAMETER_NAMES = {
          Ansible::COMPATIBILITY_MODE_V1_8 => {
            ansible_host: "ansible_ssh_host",
            ansible_password: "ansible_ssh_pass",
            ansible_port: "ansible_ssh_port",
            ansible_user: "ansible_ssh_user",
            ask_become_pass: "ask-sudo-pass",
            become: "sudo",
            become_user: "sudo-user",
          },
          Ansible::COMPATIBILITY_MODE_V2_0 => {
            ansible_host: "ansible_host",
            ansible_password: "ansible_password",
            ansible_port: "ansible_port",
            ansible_user: "ansible_user",
            ask_become_pass: "ask-become-pass",
            become: "become",
            become_user: "become-user",
          }
        }

        protected

        def initialize(machine, config)
          super
          @control_machine = nil

          @command_arguments = []
          @environment_variables = {}
          @inventory_machines = {}
          @inventory_path = nil

          @gathered_version_stdout = nil
          @gathered_version_major = nil
          @gathered_version = nil
        end

        def set_and_check_compatibility_mode
          begin
            set_gathered_ansible_version(gather_ansible_version)
          rescue StandardError => e
            # Nothing to do here, as the fallback on safe compatibility_mode is done below
            @logger.error("Error while gathering the ansible version: #{e.to_s}")
          end

          if @gathered_version_major
            if config.compatibility_mode == Ansible::COMPATIBILITY_MODE_AUTO
              detect_compatibility_mode
            elsif @gathered_version_major.to_i < 2 && config.compatibility_mode == Ansible::COMPATIBILITY_MODE_V2_0
              # A better version comparator will be needed
              # when more compatibility modes come... but so far let's keep it simple!
              raise Ansible::Errors::AnsibleCompatibilityModeConflict,
                ansible_version: @gathered_version,
                system: @control_machine,
                compatibility_mode: config.compatibility_mode
            end
          end

          if config.compatibility_mode == Ansible::COMPATIBILITY_MODE_AUTO
            config.compatibility_mode = Ansible::SAFE_COMPATIBILITY_MODE

            @machine.env.ui.warn(I18n.t("vagrant.provisioners.ansible.compatibility_mode_not_detected",
              compatibility_mode: config.compatibility_mode,
              gathered_version: @gathered_version_stdout) +
            "\n")
          end

          unless Ansible::COMPATIBILITY_MODES.slice(1..-1).include?(config.compatibility_mode)
            raise Ansible::Errors::AnsibleProgrammingError,
              message: "The config.compatibility_mode must be correctly set at this stage!",
              details: "config.compatibility_mode: '#{config.compatibility_mode}'"
          end

          @lexicon = ANSIBLE_PARAMETER_NAMES[config.compatibility_mode]
        end

        def check_files_existence
          check_path_is_a_file(config.playbook, :playbook)

          check_path_exists(config.inventory_path, :inventory_path) if config.inventory_path
          check_path_is_a_file(config.config_file, :config_file) if config.config_file
          check_path_is_a_file(config.extra_vars[1..-1], :extra_vars) if has_an_extra_vars_file_argument
          check_path_is_a_file(config.galaxy_role_file, :galaxy_role_file) if config.galaxy_role_file
          check_path_is_a_file(config.vault_password_file, :vault_password_file) if config.vault_password_file
        end

        def get_environment_variables_for_shell_execution
          shell_env_vars = []
          @environment_variables.each_pair do |k, v|
            if k =~ /ANSIBLE_SSH_ARGS|ANSIBLE_ROLES_PATH|ANSIBLE_CONFIG/
              shell_env_vars << "#{k}='#{v}'"
            else
              shell_env_vars << "#{k}=#{v}"
            end
          end
          shell_env_vars
        end

        def ansible_galaxy_command_for_shell_execution
          command_values = {
            role_file: "'#{get_galaxy_role_file}'",
            roles_path: "'#{get_galaxy_roles_path}'"
          }

          shell_command = get_environment_variables_for_shell_execution

          shell_command << config.galaxy_command % command_values

          shell_command.flatten.join(' ')
        end

        def ansible_playbook_command_for_shell_execution
          shell_command = get_environment_variables_for_shell_execution

          shell_command << config.playbook_command

          shell_args = []
          @command_arguments.each do |arg|
            if arg =~ /(--start-at-task|--limit)=(.+)/
              shell_args << %Q(#{$1}="#{$2}")
            elsif arg =~ /(--extra-vars)=(.+)/
              shell_args << %Q(%s=%s) % [$1, $2.shellescape]
            else
              shell_args << arg
            end
          end

          shell_command << shell_args

          # Add the raw arguments at the end, to give them the highest precedence
          shell_command << config.raw_arguments if config.raw_arguments

          shell_command << config.playbook

          shell_command.flatten.join(' ')
        end

        def prepare_common_command_arguments
          # By default we limit by the current machine,
          # but this can be overridden by the `limit` option.
          if config.limit
            @command_arguments << "--limit=#{Helpers::as_list_argument(config.limit)}"
          else
            @command_arguments << "--limit=#{@machine.name}"
          end

          @command_arguments << "--inventory-file=#{inventory_path}"
          @command_arguments << "--extra-vars=#{extra_vars_argument}" if config.extra_vars
          @command_arguments << "--#{@lexicon[:become]}" if config.become
          @command_arguments << "--#{@lexicon[:become_user]}=#{config.become_user}" if config.become_user
          @command_arguments << "#{verbosity_argument}" if verbosity_is_enabled?
          @command_arguments << "--vault-password-file=#{config.vault_password_file}" if config.vault_password_file
          @command_arguments << "--tags=#{Helpers::as_list_argument(config.tags)}" if config.tags
          @command_arguments << "--skip-tags=#{Helpers::as_list_argument(config.skip_tags)}" if config.skip_tags
          @command_arguments << "--start-at-task=#{config.start_at_task}" if config.start_at_task
        end

        def prepare_common_environment_variables
          # Ensure Ansible output isn't buffered so that we receive output
          # on a task-by-task basis.
          @environment_variables["PYTHONUNBUFFERED"] = 1

          # When Ansible output is piped in Vagrant integration, its default colorization is
          # automatically disabled and the only way to re-enable colors is to use ANSIBLE_FORCE_COLOR.
          @environment_variables["ANSIBLE_FORCE_COLOR"] = "true" if @machine.env.ui.color?
          # Setting ANSIBLE_NOCOLOR is "unnecessary" at the moment, but this could change in the future
          # (e.g. local provisioner [GH-2103], possible change in vagrant/ansible integration, etc.)
          @environment_variables["ANSIBLE_NOCOLOR"] = "true" if !@machine.env.ui.color?

          # Use ANSIBLE_ROLES_PATH to tell ansible-playbook where to look for roles
          # (there is no equivalent command line argument in ansible-playbook)
          @environment_variables["ANSIBLE_ROLES_PATH"] = get_galaxy_roles_path if config.galaxy_roles_path

          prepare_ansible_config_environment_variable
        end

        def prepare_ansible_config_environment_variable
          @environment_variables["ANSIBLE_CONFIG"] = config.config_file if config.config_file
        end

        # Auto-generate "safe" inventory file based on Vagrantfile,
        # unless inventory_path is explicitly provided
        def inventory_path
          if config.inventory_path
            config.inventory_path
          else
            @inventory_path ||= generate_inventory
          end
        end

        def get_inventory_host_vars_string(machine_name)
          # In Ruby, Symbol and String values are different, but
          # Vagrant has to unify them for better user experience.
          vars = config.host_vars[machine_name.to_sym]
          if !vars
            vars = config.host_vars[machine_name.to_s]
          end
          s = nil
          if vars.is_a?(Hash)
            s = vars.each.collect {
              |k, v|
                if v.is_a?(String) && v.include?(' ') && !v.match(/^('|")[^'"]+('|")$/)
                  v = %Q('#{v}')
                end
                "#{k}=#{v}"
              }.join(" ")
          elsif vars.is_a?(Array)
            s = vars.join(" ")
          elsif vars.is_a?(String)
            s = vars
          end
          if s and !s.empty? then s else nil end
        end

        def generate_inventory
          inventory = "# Generated by Vagrant\n\n"

          # This "abstract" step must fill the @inventory_machines list
          # and return the list of supported host(s)
          inventory += generate_inventory_machines

          inventory += generate_inventory_groups

          # This "abstract" step must create the inventory file and
          # return its location path
          # TODO: explain possible race conditions, etc.
          @inventory_path = ship_generated_inventory(inventory)
        end

        # Write out groups information.
        # All defined groups will be included, but only supported
        # machines and defined child groups will be included.
        def generate_inventory_groups
          groups_of_groups = {}
          defined_groups = []
          group_vars = {}
          inventory_groups = ""

          # Verify if host range patterns exist and warn
          if config.groups.any? { |gm| gm.to_s[RANGE_PATTERN] }
            @machine.ui.warn(I18n.t("vagrant.provisioners.ansible.ansible_host_pattern_detected"))
          end

          config.groups.each_pair do |gname, gmembers|
            if gname.is_a?(Symbol)
              gname = gname.to_s
            end

            if gmembers.is_a?(String)
              gmembers = gmembers.split(/\s+/)
            elsif gmembers.is_a?(Hash)
              gmembers = gmembers.each.collect{ |k, v| "#{k}=#{v}" }
            elsif !gmembers.is_a?(Array)
              gmembers = []
            end

            if gname.end_with?(":children")
              groups_of_groups[gname] = gmembers
              defined_groups << gname.sub(/:children$/, '')
            elsif gname.end_with?(":vars")
              group_vars[gname] = gmembers
            else
              defined_groups << gname
              inventory_groups += "\n[#{gname}]\n"
              gmembers.each do |gm|
                # TODO : Expand and validate host range patterns
                # against @inventory_machines list before adding them
                # otherwise abort with an error message
                if gm[RANGE_PATTERN]
                  inventory_groups += "#{gm}\n"
                end
                inventory_groups += "#{gm}\n" if @inventory_machines.include?(gm.to_sym)
              end
            end
          end

          defined_groups.uniq!
          groups_of_groups.each_pair do |gname, gmembers|
            inventory_groups += "\n[#{gname}]\n"
            gmembers.each do |gm|
              inventory_groups += "#{gm}\n" if defined_groups.include?(gm)
            end
          end

          group_vars.each_pair do |gname, gmembers|
            if defined_groups.include?(gname.sub(/:vars$/, "")) || gname == "all:vars"
              inventory_groups += "\n[#{gname}]\n" + gmembers.join("\n") + "\n"
            end
          end

          return inventory_groups
        end

        def has_an_extra_vars_file_argument
          config.extra_vars && config.extra_vars.kind_of?(String) && config.extra_vars =~ /^@.+$/
        end

        def extra_vars_argument
          if has_an_extra_vars_file_argument
            # A JSON or YAML file is referenced.
            config.extra_vars
          else
            # Expected to be a Hash after config validation.
            config.extra_vars.to_json
          end
        end

        def get_galaxy_role_file
          Helpers::expand_path_in_unix_style(config.galaxy_role_file, get_provisioning_working_directory)
        end

        def get_galaxy_roles_path
          base_dir = get_provisioning_working_directory
          if config.galaxy_roles_path
            Helpers::expand_path_in_unix_style(config.galaxy_roles_path, base_dir)
          else
            playbook_path = Helpers::expand_path_in_unix_style(config.playbook, base_dir)
            File.join(Pathname.new(playbook_path).parent, 'roles')
          end
        end

        def ui_running_ansible_command(name, command)
          @machine.ui.detail I18n.t("vagrant.provisioners.ansible.running_#{name}")
          if verbosity_is_enabled?
            # Show the ansible command in use
            @machine.env.ui.detail command
          end
        end

        def verbosity_is_enabled?
          config.verbose && !config.verbose.to_s.empty?
        end

        def verbosity_argument
          if config.verbose.to_s =~ /^-?(v+)$/
            "-#{$+}"
          else
            # safe default, in case input strays
            '-v'
          end
        end

        private

        def detect_compatibility_mode
          if !@gathered_version_major || config.compatibility_mode != Ansible::COMPATIBILITY_MODE_AUTO
            raise Ansible::Errors::AnsibleProgrammingError,
              message: "The detect_compatibility_mode() function shouldn't have been called!",
              details: %Q(config.compatibility_mode: '#{config.compatibility_mode}'
gathered version major number: '#{@gathered_version_major}'
gathered version stdout version:
#{@gathered_version_stdout})
          end

          if @gathered_version_major.to_i <= 1
            config.compatibility_mode = Ansible::COMPATIBILITY_MODE_V1_8
          else
            config.compatibility_mode = Ansible::COMPATIBILITY_MODE_V2_0
          end

          @logger.info(I18n.t("vagrant.provisioners.ansible.compatibility_mode_warning",
            compatibility_mode: config.compatibility_mode,
            ansible_version: @gathered_version) +
          "\n")
        end

        def set_gathered_ansible_version(stdout_output)
          @gathered_version_stdout = stdout_output
          if !@gathered_version_stdout.empty?
            first_line = @gathered_version_stdout.lines[0]
            ansible_version_pattern = first_line.match(/(^ansible\s+)(.+)$/)
            if ansible_version_pattern
              _, @gathered_version, _ = ansible_version_pattern.captures
              @gathered_version.strip!
              if @gathered_version
                @gathered_version_major = @gathered_version.match(/(\d)\..+$/).captures[0].to_i
              end
            end
          end
        end

      end
    end
  end
end
