#
# Author:: Dominik Richter (<dominik.richter@gmail.com>)

require_relative "train/version"
require_relative "train/options"
require_relative "train/plugins"
require_relative "train/errors"
require_relative "train/platforms"
require "addressable/uri" unless defined?(Addressable::URI)

module Train
  # Create a new transport instance, with the plugin indicated by the
  # given name.
  #
  # @param [String] name of the plugin
  # @param [Array] *args list of arguments for the plugin
  # @return [Transport] instance of the new transport or nil
  def self.create(name, *args)
    cls = load_transport(name)
    cls.new(*args) unless cls.nil?
  end

  # Retrieve the configuration options of a transport plugin.
  #
  # @param [String] name of the plugin
  # @return [Hash] map of default options
  def self.options(name)
    cls = load_transport(name)
    # Merging default_audit_log_options so that they will get listed in the options that are available.
    cls.default_options.merge(cls.default_audit_log_options) unless cls.nil?
  end

  # Load the transport plugin indicated by name. If the plugin is not
  # yet found in the plugin registry, it will be attempted to load from
  # `train/transports/plugin_name`.
  #
  # @param [String] name of the plugin
  # @return [Train::Transport] the transport plugin
  def self.load_transport(transport_name)
    transport_name = transport_name.to_s
    transport_class = Train::Plugins.registry[transport_name]
    return transport_class unless transport_class.nil?

    # Try to load the transport name from the core transports...
    require "train/transports/" + transport_name
    Train::Plugins.registry[transport_name]
  rescue LoadError => _
    begin
      # If it's not in the core transports, try loading from a train plugin gem.
      gem_name = "train-" + transport_name
      require gem_name
      return Train::Plugins.registry[transport_name]
      # rubocop: disable Lint/HandleExceptions
    rescue LoadError => _
      # rubocop: enable Lint/HandleExceptions
      # Intentionally empty rescue - we're handling it below anyway
    end

    ex = Train::PluginLoadError.new("Can't find train plugin #{transport_name}. Please install it first.")
    ex.transport_name = transport_name
    raise ex
  end

  # Legacy code to unpack a series of items from an incoming Hash
  # Inspec::Config.unpack_train_credentials now handles this in most cases that InSpec needs
  # If you need to unpack a URI, use unpack_target_from_uri
  # TODO: deprecate; can't issue a warning because train doesn't have a logger until the connection is setup (See base_connection.rb)
  def self.target_config(config = nil)
    conf = config.dup
    # Symbolize keys
    conf.keys.each do |key|
      unless key.is_a? Symbol
        conf[key.to_sym] = conf.delete(key)
      end
    end

    group_keys_and_keyfiles(conf) # TODO: move logic into SSH plugin
    return conf if conf[:target].to_s.empty?

    unpack_target_from_uri(conf[:target], conf).merge(conf)
  end

  # Given a string that looks like a URI, unpack connection credentials.
  # The name of the desired transport is always taken from the 'scheme' slot of the URI;
  # the remaining portion of the URI is parsed as if it were an HTTP URL, and then
  # the URL components are stored in the credentials hash.  It is up to the transport
  # to interpret the fields in a sensible way for that transport.
  # New transport authors are encouraged to use transport://credset format (see
  # inspec/inspec/issues/3661) rather than inventing a new field mapping.
  def self.unpack_target_from_uri(uri_string, opts = {}) # rubocop: disable Metrics/AbcSize
    creds = {}
    return creds if uri_string.empty?

    # split up the target's host/scheme configuration
    uri = parse_uri(uri_string)
    unless uri.host.nil? && uri.scheme.nil?
      creds[:backend]  ||= uri.scheme
      creds[:host]     ||= uri.hostname
      creds[:port]     ||= uri.port
      creds[:user]     ||= uri.user
      creds[:path]     ||= uri.path
      creds[:password] ||=
        if opts[:www_form_encoded_password] && !uri.password.nil?
          Addressable::URI.unencode_component(uri.password)
        else
          uri.password
        end
    end

    # ensure path is nil, if its empty; e.g. required to reset defaults for winrm # TODO: move logic into winrm plugin
    creds[:path] = nil if !creds[:path].nil? && creds[:path].to_s.empty?

    # compact! is available in ruby 2.4+
    # TODO: rewrite next line using compact! once we drop support for ruby 2.3
    creds = creds.delete_if { |_, value| value.nil? }

    # merge train options in from the URI query string
    creds.merge!(uri.query_values.map { |k, v| [k.to_sym, v] }.to_h) unless uri.query_values.nil?

    # return the updated config
    creds
  end

  # Parse a URI. Supports empty URI's with paths, e.g. `mock://`
  #
  # @param string [string] URI string, e.g. `schema://domain.com`
  # @return [Addressable::URI] parsed URI object
  def self.parse_uri(string)
    u = Addressable::URI.parse(string)
    # A use-case we want to catch is parsing empty URIs with a schema
    # e.g. mock://. To do this, we match it manually and fake the hostname
    if u.scheme && (u.host.nil? || u.host.empty?) && u.path.empty?
      case string
      when %r{^([a-z]+)://$}
        string += "dummy"
      when /^([a-z]+):$/
        string += "//dummy"
      end
      u = Addressable::URI.parse(string)
      u.host = nil
    end
    u
  rescue Addressable::URI::InvalidURIError => e
    raise Train::UserError, e
  end
  private_class_method :parse_uri

  # Examine the given credential information, and if all is well,
  # return the transport name.
  # TODO: this actually does no validation of the credential options whatsoever
  def self.validate_backend(credentials, default_transport_name = "local")
    return default_transport_name if credentials.nil?

    transport_name = credentials[:backend]

    # TODO: Determine if it is ever possible (or supported) for transport_name to be 'localhost'
    # TODO: After inspec/inspec/pull/3750 is merged, should be able to remove nil from the list
    if credentials[:sudo] && [nil, "local", "localhost"].include?(transport_name)
      raise Train::UserError, "Sudo is only valid when running against a remote host. "\
        "To run this locally with elevated privileges, run the command with `sudo ...`."
    end

    return transport_name unless transport_name.nil?

    unless credentials[:target].nil?
      # We should not get here, because if target_uri unpacking was successful,
      # it would have set credentials[:backend]
      raise Train::UserError, "Cannot determine backend from target "\
           "configuration #{credentials[:target]}. Valid example: ssh://192.168.0.1"
    end

    unless credentials[:host].nil?
      raise Train::UserError, "Host configured, but no backend was provided. Please "\
           "specify how you want to connect. Valid example: ssh://192.168.0.1"
    end

    credentials[:backend] = default_transport_name
  end

  def self.group_keys_and_keyfiles(conf)
    # in case the user specified a key-file, register it that way
    # we will clear the list of keys and put keys and key_files separately
    keys_mixed = conf[:keys]
    return if keys_mixed.nil?

    conf[:key_files] = []
    conf[:keys] = []
    keys_mixed.each do |key|
      if !key.nil? && File.file?(key)
        conf[:key_files].push(key)
      else
        conf[:keys].push(key)
      end
    end
  end
end
