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

require "digest/md5"
require "securerandom"
require "set"

require "log4r"

require "vagrant/util/counter"
require "vagrant/action/builtin/mixin_synced_folders"

require_relative "base"

module VagrantPlugins
  module Chef
    module Provisioner
      # This class implements provisioning via chef-solo.
      class ChefSolo < Base
        extend Vagrant::Util::Counter
        include Vagrant::Util::Counter
        include Vagrant::Action::Builtin::MixinSyncedFolders

        attr_reader :environments_folders
        attr_reader :cookbook_folders
        attr_reader :node_folders
        attr_reader :role_folders
        attr_reader :data_bags_folders

        def initialize(machine, config)
          super
          @logger = Log4r::Logger.new("vagrant::provisioners::chef_solo")
          @shared_folders = []
        end

        def configure(root_config)
          @cookbook_folders  = expanded_folders(@config.cookbooks_path, "cookbooks")
          @role_folders      = expanded_folders(@config.roles_path, "roles")
          @data_bags_folders = expanded_folders(@config.data_bags_path, "data_bags")
          @environments_folders = expanded_folders(@config.environments_path, "environments")
          @node_folders = expanded_folders(@config.nodes_path, "nodes")

          existing = synced_folders(@machine, cached: true)
          share_folders(root_config, "csc", @cookbook_folders, existing)
          share_folders(root_config, "csr", @role_folders, existing)
          share_folders(root_config, "csdb", @data_bags_folders, existing)
          share_folders(root_config, "cse", @environments_folders, existing)
          share_folders(root_config, "csn", @node_folders, existing)
        end

        def provision
          install_chef
          # Verify that the proper shared folders exist.
          check = []
          @shared_folders.each do |type, local_path, remote_path|
            # We only care about checking folders that have a local path, meaning
            # they were shared from the local machine, rather than assumed to
            # exist on the VM.
            check << remote_path if local_path
          end

          chown_provisioning_folder
          verify_shared_folders(check)
          verify_binary(chef_binary_path("chef-solo"))
          upload_encrypted_data_bag_secret
          setup_json
          setup_solo_config
          run_chef_solo
          delete_encrypted_data_bag_secret
        end

        # Converts paths to a list of properly expanded paths with types.
        def expanded_folders(paths, appended_folder=nil)
          # Convert the path to an array if it is a string or just a single
          # path element which contains the folder location (:host or :vm)
          paths = [paths] if paths.is_a?(String) || paths.first.is_a?(Symbol)

          results = []
          paths.each do |type, path|
            # Create the local/remote path based on whether this is a host
            # or VM path.
            local_path = nil
            remote_path = nil
            if type == :host
              # Get the expanded path that the host path points to
              local_path = File.expand_path(path, @machine.env.root_path)

              if File.exist?(local_path)
                # Path exists on the host, setup the remote path. We use
                # the MD5 of the local path so that it is predictable.
                key         = Digest::MD5.hexdigest(local_path)
                remote_path = "#{guest_provisioning_path}/#{key}"
              else
                appended_folder = "cookbooks" if appended_folder.nil?
                @machine.ui.warn(I18n.t("vagrant.provisioners.chef.#{appended_folder}_folder_not_found_warning",
                                       path: local_path.to_s))
                next
              end
            else
              # Path already exists on the virtual machine. Expand it
              # relative to where we're provisioning.

              # Remove drive letter if running on a windows host. This is a bit
              # of a hack but is the most portable way I can think of at the moment
              # to achieve this. Otherwise, Vagrant attempts to share at some crazy
              # path like /home/vagrant/c:/foo/bar
              remote_path = File.expand_path(path.sub(/^[a-zA-Z]:\//, "/"), guest_provisioning_path.sub(/^[a-zA-Z]:\//, "/"))
              remote_path.sub!(/^[a-zA-Z]:\//, "/")
            end

            # If we have specified a folder name to append then append it
            if type == :host
              remote_path += "/#{appended_folder}" if appended_folder
            end

            # Append the result
            results << [type, local_path, remote_path]
          end

          results
        end

        # Shares the given folders with the given prefix. The folders should
        # be of the structure resulting from the `expanded_folders` function.
        def share_folders(root_config, prefix, folders, existing=nil)
          existing_set = Set.new
          (existing || []).each do |_, fs|
            fs.each do |id, data|
              existing_set.add(data[:guestpath])
            end
          end

          folders.each do |type, local_path, remote_path|
            next if type != :host

            # If this folder already exists, then we don't share it, it means
            # it was already put down on disk.
            #
            # NOTE: This is currently commented out because it was causing
            # major bugs (GH-5199). We will investigate why this is in more
            # detail for 1.8.0, but we wanted to fix this in a patch release
            # and this was the hammer that did that.
=begin
            if existing_set.include?(remote_path)
              @logger.debug("Not sharing #{local_path}, exists as #{remote_path}")
              next
            end
=end

            key = Digest::MD5.hexdigest(remote_path)
            key = key[0..8]

            opts = {}
            opts[:id] = "v-#{prefix}-#{key}"
            opts[:type] = @config.synced_folder_type if @config.synced_folder_type

            root_config.vm.synced_folder(local_path, remote_path, opts)
          end

          @shared_folders += folders
        end

        def setup_solo_config
          setup_config("provisioners/chef_solo/solo", "solo.rb", solo_config)
        end

        def solo_config
          {
            cookbooks_path: guest_paths(@cookbook_folders),
            recipe_url: @config.recipe_url,
            nodes_path: guest_paths(@node_folders),
            roles_path: guest_paths(@role_folders),
            data_bags_path: guest_paths(@data_bags_folders),
            environments_path: guest_paths(@environments_folders).first
          }
        end

        def run_chef_solo
          if @config.run_list && @config.run_list.empty?
            @machine.ui.warn(I18n.t("vagrant.chef_run_list_empty"))
          end

          command = CommandBuilder.command(:solo, @config,
            windows: windows?,
            colored: @machine.env.ui.color?,
            legacy_mode: @config.legacy_mode,
          )

          still_active = 259 #provisioner has asked chef to reboot

          @config.attempts.times do |attempt|
            exit_status = 0
            while exit_status == 0 || exit_status == still_active
              if @machine.guest.capability?(:wait_for_reboot)
                @machine.guest.capability(:wait_for_reboot)
              elsif attempt > 0
                sleep 10
                @machine.communicate.wait_for_ready(@machine.config.vm.boot_timeout)
              end
              if attempt == 0
                @machine.ui.info I18n.t("vagrant.provisioners.chef.running_solo")
              else
                @machine.ui.info I18n.t("vagrant.provisioners.chef.running_solo_again")
              end

              opts = { error_check: false, elevated: true }
              exit_status = @machine.communicate.sudo(command, opts) do |type, data|
                # Output the data with the proper color based on the stream.
                color = type == :stdout ? :green : :red

                data = data.chomp
                next if data.empty?

                @machine.ui.info(data, color: color)
              end

              # There is no need to run Chef again if it converges
              return if exit_status == 0
            end
          end

          # If we reached this point then Chef never converged! Error.
          raise ChefError, :no_convergence
        end

        def verify_shared_folders(folders)
          folders.each do |folder|
            @logger.debug("Checking for shared folder: #{folder}")
            if !@machine.communicate.test("test -d #{folder}", sudo: true)
              raise ChefError, :missing_shared_folders
            end
          end
        end

        protected

        # Extracts only the remote paths from a list of folders
        def guest_paths(folders)
          folders.map { |parts| parts[2] }
        end
      end
    end
  end
end
