require 'puppet/provider/package_targetable'
require 'uri'

# Ruby gems support.
Puppet::Type.type(:package).provide :gem, :parent => Puppet::Provider::Package::Targetable do
  desc "Ruby Gem support. If a URL is passed via `source`, then that URL is
    appended to the list of remote gem repositories; to ensure that only the
    specified source is used, also pass `--clear-sources` via `install_options`.
    If source is present but is not a valid URL, it will be interpreted as the
    path to a local gem file. If source is not present, the gem will be
    installed from the default gem repositories. Note that to modify this for Windows, it has to be a valid URL.

    This provider supports the `install_options` and `uninstall_options` attributes,
    which allow command-line flags to be passed to the gem command.
    These options should be specified as a string (e.g. '--flag'), a hash (e.g. {'--flag' => 'value'}),
    or an array where each element is either a string or a hash."

  has_feature :versionable, :install_options, :uninstall_options, :targetable

  # Override the specificity method to return 1 if gem is not set as default provider
  def self.specificity
    match = default_match
    length = match ? match.length : 0

    return 1 if length == 0

    super
  end

  # Define the default provider package command name when the provider is targetable.
  # Required by Puppet::Provider::Package::Targetable::resource_or_provider_command

  def self.provider_command
    command(:gemcmd)
  end

  # Define the default provider package command as optional when the provider is targetable.
  # Doing do defers the evaluation of provider suitability until all commands are evaluated.

  has_command(:gemcmd, 'gem') do
    is_optional
  end

  # CommandDefiner in provider.rb creates convenience execution methods that set failonfail, combine, and optionally, environment.
  # And when a child provider defines its own command via commands() or has_command(), the provider-specific path is always returned by command().
  # But when the convenience execution method is invoked, the last convenience method to be defined is executed.
  # This makes invoking those convenience execution methods unsuitable for inherited providers.
  #
  # In this case, causing the puppet_gem provider to inherit the parent gem provider's convenience gemcmd() methods, with the wrong path.

  def self.execute_gem_command(command, command_options)
    validate_command(command)
    cmd = [command] << command_options

    execute(cmd, {:failonfail => true, :combine => true, :custom_environment => {"HOME"=>ENV["HOME"]}})
  end

  def self.instances(target_command = nil)
    if target_command
      command = target_command
    else
      command = provider_command
      # The default provider package command is optional.
      return [] unless command
    end

    gemlist(:command => command, :local => true).collect do |pkg|
      # Track the command when the provider is targetable.
      pkg[:command] = command
      new(pkg)
    end
  end

  def self.gemlist(options)
    command_options = ["list"]

    if options[:local]
      command_options << "--local"
    else
      command_options << "--remote"
    end
    if options[:source]
      command_options << "--source" << options[:source]
    end
    if name = options[:justme]
      command_options << '\A' + name + '\z'
    end

    begin
      list = execute_gem_command(options[:command], command_options).lines.
        map {|set| gemsplit(set) }.
        reject {|x| x.nil? }
    rescue Puppet::ExecutionFailure => detail
      raise Puppet::Error, _("Could not list gems: %{detail}") % { detail: detail }, detail.backtrace
    end

    if options[:justme]
      return list.shift
    else
      return list
    end
  end

  def self.gemsplit(desc)
    # `gem list` when output console has a line like:
    # *** LOCAL GEMS ***
    # but when it's not to the console that line
    # and all blank lines are stripped
    # so we don't need to check for them

    if desc =~ /^(\S+)\s+\((.+)\)/
      gem_name = $1
      versions = $2.sub('default: ', '').split(/,\s*/)
      {
        :name     => gem_name,
        :ensure   => versions.map{|v| v.split[0]},
        :provider => name
      }
    else
      Puppet.warning _("Could not match %{desc}") % { desc: desc } unless desc.chomp.empty?
      nil
    end
  end

  def insync?(is)
    return false unless is && is != :absent

    begin
      dependency = Gem::Dependency.new('', resource[:ensure])
    rescue ArgumentError
      # Bad requirements will cause an error during gem command invocation, so just return not in sync
      return false
    end

    is = [is] unless is.is_a? Array

    # Check if any version matches the dependency
    is.any? { |version| dependency.match?('', version) }
  end

  def rubygem_version(command)
    command_options = ["--version"]
    self.class.execute_gem_command(command, command_options)
  end

  def install(useversion = true)
    command = resource_or_provider_command
    command_options = ["install"]
    command_options += install_options if resource[:install_options]

    if Puppet.features.microsoft_windows?
      version = resource[:ensure]
      command_options << "-v" << %Q["#{version}"] if (! resource[:ensure].is_a? Symbol) and useversion
    else
      command_options << "-v" << resource[:ensure] if (! resource[:ensure].is_a? Symbol) and useversion
    end

    if Puppet::Util::Package.versioncmp(rubygem_version(command), '2.0.0') == -1
      command_options << "--no-rdoc" << "--no-ri"
    else
      command_options << "--no-document"
    end

    if source = resource[:source]
      begin
        uri = URI.parse(source)
      rescue => detail
        self.fail Puppet::Error, _("Invalid source '%{uri}': %{detail}") % { uri: uri, detail: detail }, detail
      end

      case uri.scheme
      when nil
        # no URI scheme => interpret the source as a local file
        command_options << source
      when /file/i
        command_options << uri.path
      when 'puppet'
        # we don't support puppet:// URLs (yet)
        raise Puppet::Error.new(_("puppet:// URLs are not supported as gem sources"))
      else
        # check whether it's an absolute file path to help Windows out
        if Puppet::Util.absolute_path?(source)
          command_options << source
        else
          # interpret it as a gem repository
          command_options << "--source" << "#{source}" << resource[:name]
        end
      end
    else
      command_options << resource[:name]
    end

    output = self.class.execute_gem_command(command, command_options)
    # Apparently some gem versions don't exit non-0 on failure.
    self.fail _("Could not install: %{output}") % { output: output.chomp } if output.include?("ERROR")
  end

  def latest
    command = resource_or_provider_command
    options = { :command => command, :justme => resource[:name] }
    options[:source] = resource[:source] unless resource[:source].nil?
    pkg = self.class.gemlist(options)
    pkg[:ensure][0]
  end

  def query
    command = resource_or_provider_command
    options = { :command => command, :justme => resource[:name], :local => true }
    pkg = self.class.gemlist(options)
    pkg[:command] = command unless pkg.nil?
    pkg
  end

  def uninstall
    command = resource_or_provider_command
    command_options = ["uninstall"]
    command_options << "--executables" << "--all" << resource[:name]
    command_options += uninstall_options if resource[:uninstall_options]
    output = self.class.execute_gem_command(command, command_options)
    # Apparently some gem versions don't exit non-0 on failure.
    self.fail _("Could not uninstall: %{output}") % { output: output.chomp } if output.include?("ERROR")
  end

  def update
    self.install(false)
  end

  def install_options
    join_options(resource[:install_options])
  end

  def uninstall_options
    join_options(resource[:uninstall_options])
  end
end
