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

require "json"
require "log4r"

module VagrantPlugins
  module DockerProvider
    class Driver
      class Compose < Driver

        # @return [Integer] Maximum number of seconds to wait for lock
        LOCK_TIMEOUT = 60
        # @return [String] Compose file format version
        COMPOSE_VERSION = "2".freeze

        # @return [Pathname] data directory to store composition
        attr_reader :data_directory
        # @return [Vagrant::Machine]
        attr_reader :machine

        # Create a new driver instance
        #
        # @param [Vagrant::Machine] machine Machine instance for this driver
        def initialize(machine)
          if !Vagrant::Util::Which.which("docker-compose")
            raise Errors::DockerComposeNotInstalledError
          end
          super()
          @machine = machine
          @data_directory = Pathname.new(machine.env.local_data_path).
            join("docker-compose")
          @data_directory.mkpath
          @logger = Log4r::Logger.new("vagrant::docker::driver::compose")
          @compose_lock = Mutex.new
          @logger.debug("Docker compose driver initialize for machine `#{@machine.name}` (`#{@machine.id}`)")
          @logger.debug("Data directory for composition file `#{@data_directory}`")
        end

        # Updates the docker compose config file with the given arguments
        #
        # @param [String] dir - local directory or git repo URL
        # @param [Hash] opts - valid key: extra_args
        # @param [Block] block
        # @return [Nil]
        def build(dir, **opts, &block)
          name = machine.name.to_s
          @logger.debug("Applying build for `#{name}` using `#{dir}` directory.")
          begin
            update_composition do |composition|
              services = composition["services"] ||= {}
              services[name] ||= {}
              services[name]["build"] = {"context" => dir}
              # Extract custom dockerfile location if set
              if opts[:extra_args] && opts[:extra_args].include?("--file")
                services[name]["build"]["dockerfile"] = opts[:extra_args][opts[:extra_args].index("--file") + 1]
              end
              # Extract any build args that can be found
              case opts[:extra_args]
              when Array
                if opts[:extra_args].include?("--build-arg")
                  idx = 0
                  extra_args = {}
                  while(idx < opts[:extra_args].size)
                    arg_value = opts[:extra_args][idx]
                    idx += 1
                    if arg_value.start_with?("--build-arg")
                      if !arg_value.include?("=")
                        arg_value = opts[:extra_args][idx]
                        idx += 1
                      end
                      key, val = arg_value.to_s.split("=", 2).to_s.split("=")
                      extra_args[key] = val
                    end
                  end
                end
              when Hash
                services[name]["build"]["args"] = opts[:extra_args]
              end
            end
          rescue => error
            @logger.error("Failed to apply build using `#{dir}` directory: #{error.class} - #{error}")
            update_composition do |composition|
              composition["services"].delete(name)
            end
            raise
          end
        end

        def create(params, **opts, &block)
          # NOTE: Use the direct machine name as we don't
          # need to worry about uniqueness with compose
          name    = machine.name.to_s
          image   = params.fetch(:image)
          links   = Array(params.fetch(:links, [])).map do |link|
            case link
            when Array
              link
            else
              link.to_s.split(":")
            end
          end
          ports   = Array(params[:ports])
          volumes = Array(params[:volumes]).map do |v|
            v = v.to_s
            host, guest = v.split(":", 2)
            if v.include?(":") && (Vagrant::Util::Platform.windows? || Vagrant::Util::Platform.wsl?)
              host = Vagrant::Util::Platform.windows_path(host)
              # NOTE: Docker does not support UNC style paths (which also
              # means that there's no long path support). Hopefully this
              # will be fixed someday and the gsub below can be removed.
              host.gsub!(/^[^A-Za-z]+/, "")
            end
            # if host path is a volume key, don't expand it.
            # if both exist (a path and a key) show warning and move on
            # otherwise assume it's a realative path and expand the host path
            compose_config = get_composition
            if compose_config["volumes"] && compose_config["volumes"].keys.include?(host)
              if File.directory?(@machine.env.cwd.join(host).to_s)
                @machine.env.ui.warn(I18n.t("docker_provider.volume_path_not_expanded",
                                           host: host))
              end
            else
              @logger.debug("Path expanding #{host} to current Vagrant working dir instead of docker-compose config file directory")
              host = @machine.env.cwd.join(host).to_s
            end
            "#{host}:#{guest}"
          end
          cmd     = Array(params.fetch(:cmd))
          env     = Hash[*params.fetch(:env).flatten.map(&:to_s)]
          expose  = Array(params[:expose])
          @logger.debug("Creating container `#{name}`")
          begin
            update_args = [:apply]
            update_args.push(:detach) if params[:detach]
            update_args << block
            update_composition(*update_args) do |composition|
              services = composition["services"] ||= {}
              services[name] ||= {}
              if params[:extra_args].is_a?(Hash)
                services[name].merge!(
                  Hash[
                    params[:extra_args].map{ |k, v|
                      [k.to_s, v]
                    }
                  ]
                )
              end
              services[name].merge!(
                "environment" => env,
                "expose" => expose,
                "ports" => ports,
                "volumes" => volumes,
                "links" => links,
                "command" => cmd
              )
              services[name]["image"] = image if image
              services[name]["hostname"] = params[:hostname] if params[:hostname]
              services[name]["privileged"] = true if params[:privileged]
              services[name]["pty"] = true if params[:pty]
            end
          rescue => error
            @logger.error("Failed to create container `#{name}`: #{error.class} - #{error}")
            update_composition do |composition|
              composition["services"].delete(name)
            end
            raise
          end
          get_container_id(name)
        end

        def rm(cid)
          if created?(cid)
            destroy = false
            synchronized do
              compose_execute("rm", "-f", machine.name.to_s)
              update_composition do |composition|
                if composition["services"] && composition["services"].key?(machine.name.to_s)
                  @logger.info("Removing container `#{machine.name}`")
                  if composition["services"].size > 1
                    composition["services"].delete(machine.name.to_s)
                  else
                    destroy = true
                  end
                end
              end
              if destroy
                @logger.info("No containers remain. Destroying full environment.")
                compose_execute("down", "--volumes", "--rmi", "local")
                @logger.info("Deleting composition path `#{composition_path}`")
                composition_path.delete
              end
            end
          end
        end

        def rmi(*_)
          true
        end

        def created?(cid)
          result = super
          if !result
            composition = get_composition
            if composition["services"] && composition["services"].has_key?(machine.name.to_s)
              result = true
            end
          end
          result
        end

        private

        # Lookup the ID for the container with the given name
        #
        # @param [String] name Name of container
        # @return [String] Container ID
        def get_container_id(name)
          compose_execute("ps", "-q", name).chomp
        end

        # Execute a `docker-compose` command
        def compose_execute(*cmd, **opts, &block)
          synchronized do
            execute("docker-compose", "-f", composition_path.to_s,
              "-p", machine.env.cwd.basename.to_s, *cmd, **opts, &block)
          end
        end

        # Apply any changes made to the composition
        def apply_composition!(*args)
          block = args.detect{|arg| arg.is_a?(Proc) }
          execute_args = ["up", "--remove-orphans"]
          if args.include?(:detach)
            execute_args << "-d"
          end
          machine.env.lock("compose", retry: true) do
            if block
              compose_execute(*execute_args, &block)
            else
              compose_execute(*execute_args)
            end
          end
        end

        # Update the composition and apply changes if requested
        #
        # @param [Boolean] apply Apply composition changes
        def update_composition(*args)
          synchronized do
            machine.env.lock("compose", retry: true) do
              composition = get_composition
              result = yield composition
              write_composition(composition)
              if args.include?(:apply) || (args.include?(:conditional) && result)
                apply_composition!(*args)
              end
            end
          end
        end

        # @return [Hash] current composition contents
        def get_composition
          composition = {"version" => COMPOSE_VERSION.dup}
          if composition_path.exist?
            composition = Vagrant::Util::DeepMerge.deep_merge(composition, YAML.load(composition_path.read))
          end
          composition = Vagrant::Util::DeepMerge.deep_merge(composition, machine.provider_config.compose_configuration.dup)
          @logger.debug("Fetched composition with provider configuration applied: #{composition}")
          composition
        end

        # Save the composition
        #
        # @param [Hash] composition New composition
        def write_composition(composition)
          @logger.debug("Saving composition to `#{composition_path}`: #{composition}")
          tmp_file = Tempfile.new("vagrant-docker-compose")
          tmp_file.write(composition.to_yaml)
          tmp_file.close
          synchronized do
            FileUtils.mv(tmp_file.path, composition_path.to_s)
          end
        end

        # @return [Pathname] path to the docker-compose.yml file
        def composition_path
          data_directory.join("docker-compose.yml")
        end

        def synchronized
          if !@compose_lock.owned?
            timeout = LOCK_TIMEOUT.to_f
            until @compose_lock.owned?
              if @compose_lock.try_lock
                if timeout > 0
                  timeout -= sleep(1)
                else
                  raise Errors::ComposeLockTimeoutError
                end
              end
            end
            got_lock = true
          end
          begin
            result = yield
          ensure
            @compose_lock.unlock if got_lock
          end
          result
        end
      end
    end
  end
end
