# frozen_string_literal: true

require 'etc'
require 'logging'
require 'pathname'
require 'bolt/project'
require 'bolt/logger'
require 'bolt/util'
require 'bolt/config/options'

module Bolt
  class UnknownTransportError < Bolt::Error
    def initialize(transport, uri = nil)
      msg = uri.nil? ? "Unknown transport #{transport}" : "Unknown transport #{transport} found for #{uri}"
      super(msg, 'bolt/unknown-transport')
    end
  end

  class Config
    include Bolt::Config::Options

    attr_reader :config_files, :logs, :data, :transports, :project, :modified_concurrency, :deprecations

    BOLT_CONFIG_NAME = 'bolt.yaml'
    BOLT_DEFAULTS_NAME = 'bolt-defaults.yaml'

    # The default concurrency value that is used when the ulimit is not low (i.e. < 700)
    DEFAULT_DEFAULT_CONCURRENCY = 100

    def self.default
      new(Bolt::Project.default_project, {})
    end

    def self.from_project(project, overrides = {})
      logs = []
      conf = if project.project_file == project.config_file
               project.data
             else
               c = Bolt::Util.read_optional_yaml_hash(project.config_file, 'config')
               logs << { debug: "Loaded configuration from #{project.config_file}" } if File.exist?(project.config_file)
               c
             end

      data = load_defaults(project).push(
        filepath: project.config_file,
        data: conf,
        logs: logs,
        deprecations: []
      )

      new(project, data, overrides)
    end

    def self.from_file(configfile, overrides = {})
      project = Bolt::Project.create_project(Pathname.new(configfile).expand_path.dirname)
      logs = []

      conf = if project.project_file == project.config_file
               project.data
             else
               c = Bolt::Util.read_yaml_hash(configfile, 'config')
               logs << { debug: "Loaded configuration from #{configfile}" }
               c
             end

      data = load_defaults(project).push(
        filepath: configfile,
        data: conf,
        logs: logs,
        deprecations: []
      )

      new(project, data, overrides)
    end

    def self.system_path
      # Lazy-load expensive gem code
      require 'win32/dir' if Bolt::Util.windows?

      if Bolt::Util.windows?
        Pathname.new(File.join(Dir::COMMON_APPDATA, 'PuppetLabs', 'bolt', 'etc'))
      else
        Pathname.new(File.join('/etc', 'puppetlabs', 'bolt'))
      end
    end

    def self.user_path
      Pathname.new(File.expand_path(File.join('~', '.puppetlabs', 'etc', 'bolt')))
    rescue StandardError
      nil
    end

    # Loads a 'bolt-defaults.yaml' file, which contains default configuration that applies to all
    # projects. This file does not allow project-specific configuration such as 'hiera-config' and
    # 'inventoryfile', and nests all default inventory configuration under an 'inventory-config' key.
    def self.load_bolt_defaults_yaml(dir)
      filepath = dir + BOLT_DEFAULTS_NAME
      data     = Bolt::Util.read_yaml_hash(filepath, 'config')
      logs     = [{ debug: "Loaded configuration from #{filepath}" }]

      # Warn if 'bolt.yaml' detected in same directory.
      if File.exist?(bolt_yaml = dir + BOLT_CONFIG_NAME)
        logs.push(
          warn: "Detected multiple configuration files: ['#{bolt_yaml}', '#{filepath}']. '#{bolt_yaml}' "\
          "will be ignored."
        )
      end

      # Remove project-specific config such as hiera-config, etc.
      project_config = data.slice(*(BOLT_PROJECT_OPTIONS - BOLT_DEFAULTS_OPTIONS))

      if project_config.any?
        data.reject! { |key, _| project_config.include?(key) }
        logs.push(
          warn: "Unsupported project configuration detected in '#{filepath}': #{project_config.keys}. "\
          "Project configuration should be set in 'bolt-project.yaml'."
        )
      end

      # Remove top-level transport config such as transport, ssh, etc.
      transport_config = data.slice(*INVENTORY_OPTIONS.keys)

      if transport_config.any?
        data.reject! { |key, _| transport_config.include?(key) }
        logs.push(
          warn: "Unsupported inventory configuration detected in '#{filepath}': #{transport_config.keys}. "\
          "Transport configuration should be set under the 'inventory-config' option or "\
          "in 'inventory.yaml'."
        )
      end

      # Move data under inventory-config to top-level so it can be easily merged with
      # config from other sources. Error early if inventory-config is not a hash or
      # has a plugin reference.
      if data.key?('inventory-config')
        unless data['inventory-config'].is_a?(Hash)
          raise Bolt::ValidationError,
                "Option 'inventory-config' must be of type Hash, received #{data['inventory-config']} "\
                "#{data['inventory-config']} (file: #{filepath})"
        end

        if data['inventory-config'].key?('_plugin')
          raise Bolt::ValidationError,
                "Found unsupported key '_plugin' for option 'inventory-config'; supported keys are "\
                "'#{INVENTORY_OPTIONS.keys.join("', '")}' (file: #{filepath})"
        end

        data = data.merge(data.delete('inventory-config'))
      end

      { filepath: filepath, data: data, logs: logs, deprecations: [] }
    end

    # Loads a 'bolt.yaml' file, the legacy configuration file. There's no special munging needed
    # here since Bolt::Config will just ignore any invalid keys.
    def self.load_bolt_yaml(dir)
      filepath = dir + BOLT_CONFIG_NAME
      data     = Bolt::Util.read_yaml_hash(filepath, 'config')
      logs     = [{ debug: "Loaded configuration from #{filepath}" }]
      deprecations = [{ type: 'Using bolt.yaml for system configuration',
                        msg: "Configuration file #{filepath} is deprecated and will be removed in a future version "\
                        "of Bolt. Use '#{dir + BOLT_DEFAULTS_NAME}' instead." }]

      { filepath: filepath, data: data, logs: logs, deprecations: deprecations }
    end

    def self.load_defaults(project)
      confs = []

      # Load system-level config. Prefer a 'bolt-defaults.yaml' file, but fall back to the
      # legacy 'bolt.yaml' file. If the project-level config file is also the system-level
      # config file, don't load it a second time.
      if File.exist?(system_path + BOLT_DEFAULTS_NAME)
        confs << load_bolt_defaults_yaml(system_path)
      elsif File.exist?(system_path + BOLT_CONFIG_NAME) &&
            (system_path + BOLT_CONFIG_NAME) != project.config_file
        confs << load_bolt_yaml(system_path)
      end

      # Load user-level config if there is a homedir. Prefer a 'bolt-defaults.yaml' file, but
      # fall back to the legacy 'bolt.yaml' file.
      if user_path
        if File.exist?(user_path + BOLT_DEFAULTS_NAME)
          confs << load_bolt_defaults_yaml(user_path)
        elsif File.exist?(user_path + BOLT_CONFIG_NAME)
          confs << load_bolt_yaml(user_path)
        end
      end

      confs
    end

    def initialize(project, config_data, overrides = {})
      unless config_data.is_a?(Array)
        config_data = [{ filepath: project.config_file,
                         data: config_data,
                         logs: [],
                         deprecations: [] }]
      end

      @logger       = Bolt::Logger.logger(self)
      @project      = project
      @logs         = @project.logs.dup
      @deprecations = @project.deprecations.dup
      @transports   = {}
      @config_files = []

      default_data = {
        'apply_settings'      => {},
        'color'               => true,
        'compile-concurrency' => Etc.nprocessors,
        'concurrency'         => default_concurrency,
        'format'              => 'human',
        'log'                 => { 'console' => {} },
        'plugin_hooks'        => {},
        'plugins'             => {},
        'puppetdb'            => {},
        'puppetfile'          => {},
        'save-rerun'          => true,
        'transport'           => 'ssh'
      }

      if project.path.directory?
        default_data['log']['bolt-debug.log'] = {
          'level' => 'debug',
          'append' => false
        }
      end

      loaded_data = config_data.each_with_object([]) do |data, acc|
        @logs.concat(data[:logs]) if data[:logs].any?
        @deprecations.concat(data[:deprecations]) if data[:deprecations].any?

        if data[:data].any?
          @config_files.push(data[:filepath])
          acc.push(data[:data])
        end
      end

      override_data = normalize_overrides(overrides)

      # If we need to lower concurrency and concurrency is not configured
      ld_concurrency = loaded_data.map(&:keys).flatten.include?('concurrency')
      @modified_concurrency = default_concurrency != DEFAULT_DEFAULT_CONCURRENCY &&
                              !ld_concurrency &&
                              !override_data.key?('concurrency')

      @data = merge_config_layers(default_data, *loaded_data, override_data)

      TRANSPORT_CONFIG.each do |transport, config|
        @transports[transport] = config.new(@data.delete(transport), @project.path)
      end

      finalize_data
      validate
    end

    # Transforms CLI options into a config hash that can be merged with
    # default and loaded config.
    def normalize_overrides(options)
      opts = options.transform_keys(&:to_s)

      # Pull out config options. We need to add 'transport' as it's not part of the
      # OPTIONS hash but is a valid option that can be set with the --transport CLI option
      overrides = opts.slice(*OPTIONS.keys, 'transport')

      # Pull out transport config options
      TRANSPORT_CONFIG.each do |transport, config|
        overrides[transport] = opts.slice(*config.options)
      end

      # Set console log to debug if in debug mode
      if options[:debug]
        overrides['log'] = { 'console' => { 'level' => :debug } }
      end

      if options[:puppetfile_path]
        @puppetfile = options[:puppetfile_path]
      end

      overrides['trace'] = opts['trace'] if opts.key?('trace')

      overrides
    end

    # Merge configuration from all sources into a single hash. Precedence from lowest to highest:
    # defaults, system-wide, user-level, project-level, CLI overrides
    def merge_config_layers(*config_data)
      config_data.inject({}) do |acc, config|
        acc.merge(config) do |key, val1, val2|
          case key
          # Plugin config is shallow merged for each plugin
          when 'plugins'
            val1.merge(val2) { |_, v1, v2| v1.merge(v2) }
          # Transports are deep merged
          when *TRANSPORT_CONFIG.keys
            Bolt::Util.deep_merge(val1, val2)
          # Hash values are shallow merged
          when 'puppetdb', 'plugin_hooks', 'apply_settings', 'log'
            val1.merge(val2)
          # All other values are overwritten
          else
            val2
          end
        end
      end
    end

    def deep_clone
      Bolt::Util.deep_clone(self)
    end

    private def finalize_data
      if @data['log'].is_a?(Hash)
        @data['log'] = update_logs(@data['log'])
      end

      # Expand paths relative to the project. Any settings that came from the
      # CLI will already be absolute, so the expand will be skipped.
      if @data.key?('modulepath')
        moduledirs = if data['modulepath'].is_a?(String)
                       data['modulepath'].split(File::PATH_SEPARATOR)
                     else
                       data['modulepath']
                     end
        @data['modulepath'] = moduledirs.map do |moduledir|
          File.expand_path(moduledir, @project.path)
        end
      end

      %w[hiera-config inventoryfile trusted-external-command].each do |opt|
        @data[opt] = File.expand_path(@data[opt], @project.path) if @data.key?(opt)
      end

      # Filter hashes to only include valid options
      @data['apply_settings'] = @data['apply_settings'].slice(*OPTIONS['apply_settings'][:properties].keys)
      @data['puppetfile'] = @data['puppetfile'].slice(*OPTIONS['puppetfile'][:properties].keys)
    end

    private def normalize_log(target)
      return target if target == 'console'
      target = target[5..-1] if target.start_with?('file:')
      'file:' + File.expand_path(target, @project.path)
    end

    private def update_logs(logs)
      begin
        if logs['bolt-debug.log'] && logs['bolt-debug.log'] != 'disable'
          FileUtils.touch(File.expand_path('bolt-debug.log', @project.path))
        end
      rescue StandardError
        logs.delete('bolt-debug.log')
      end

      logs.each_with_object({}) do |(key, val), acc|
        # Remove any disabled logs
        next if val == 'disable'

        name = normalize_log(key)

        # But otherwise it has to be a Hash
        unless val.is_a?(Hash)
          raise Bolt::ValidationError,
                "config of log #{name} must be a Hash, received #{val.class} #{val.inspect}"
        end

        acc[name] = val.slice('append', 'level')
                       .transform_keys(&:to_sym)

        if (v = acc[name][:level])
          unless v.is_a?(String) || v.is_a?(Symbol)
            raise Bolt::ValidationError,
                  "level of log #{name} must be a String or Symbol, received #{v.class} #{v.inspect}"
          end
          unless Bolt::Logger.valid_level?(v)
            raise Bolt::ValidationError,
                  "level of log #{name} must be one of #{Bolt::Logger.levels.join(', ')}; received #{v}"
          end
        end

        if (v = acc[name][:append]) && v != true && v != false
          raise Bolt::ValidationError,
                "append flag of log #{name} must be a Boolean, received #{v.class} #{v.inspect}"
        end
      end
    end

    def validate
      if @data['future']
        msg = "Configuration option 'future' no longer exposes future behavior."
        @logs << { warn: msg }
      end

      if @project.modules && @data['modulepath']&.include?(@project.managed_moduledir)
        raise Bolt::ValidationError,
              "Found invalid path in modulepath: #{@project.managed_moduledir}. This path "\
              "is automatically appended to the modulepath and cannot be configured."
      end

      keys = OPTIONS.keys - %w[plugins plugin_hooks puppetdb]
      keys.each do |key|
        next unless Bolt::Util.references?(@data[key])
        valid_keys = TRANSPORT_CONFIG.keys + %w[plugins plugin_hooks puppetdb]
        raise Bolt::ValidationError,
              "Found unsupported key _plugin in config setting #{key}. Plugins are only available in "\
              "#{valid_keys.join(', ')}."
      end

      unless concurrency.is_a?(Integer) && concurrency > 0
        raise Bolt::ValidationError,
              "Concurrency must be a positive Integer, received #{concurrency.class} #{concurrency}"
      end

      unless compile_concurrency.is_a?(Integer) && compile_concurrency > 0
        raise Bolt::ValidationError,
              "Compile concurrency must be a positive Integer, received #{compile_concurrency.class} "\
              "#{compile_concurrency}"
      end

      compile_limit = 2 * Etc.nprocessors
      unless compile_concurrency < compile_limit
        raise Bolt::ValidationError, "Compilation is CPU-intensive, set concurrency less than #{compile_limit}"
      end

      unless %w[human json rainbow].include? format
        raise Bolt::ValidationError, "Unsupported format: '#{format}'"
      end

      Bolt::Util.validate_file('hiera-config', @data['hiera-config']) if @data['hiera-config']
      Bolt::Util.validate_file('trusted-external-command', trusted_external) if trusted_external

      unless TRANSPORT_CONFIG.include?(transport)
        raise UnknownTransportError, transport
      end
    end

    def default_inventoryfile
      @project.inventory_file
    end

    def rerunfile
      @project.rerunfile
    end

    def hiera_config
      @data['hiera-config'] || @project.hiera_config
    end

    def puppetfile
      @puppetfile || @project.puppetfile
    end

    def modulepath
      path = @data['modulepath'] || @project.modulepath

      if @project.modules && ENV['BOLT_MODULE_FEATURE']
        path + [@project.managed_moduledir]
      else
        path
      end
    end

    def modulepath=(value)
      @data['modulepath'] = value
    end

    def concurrency
      @data['concurrency']
    end

    def format
      @data['format']
    end

    def format=(value)
      @data['format'] = value
    end

    def trace
      @data['trace']
    end

    def log
      @data['log']
    end

    def puppetdb
      @data['puppetdb']
    end

    def color
      @data['color']
    end

    def save_rerun
      @data['save-rerun']
    end

    def inventoryfile
      @data['inventoryfile']
    end

    def compile_concurrency
      @data['compile-concurrency']
    end

    def puppetfile_config
      @data['puppetfile']
    end

    def plugins
      @data['plugins']
    end

    def plugin_hooks
      @data['plugin_hooks']
    end

    def trusted_external
      @data['trusted-external-command']
    end

    def apply_settings
      @data['apply_settings']
    end

    def transport
      @data['transport']
    end

    # Check if there is a case-insensitive match to the path
    def check_path_case(type, paths)
      return if paths.nil?
      matches = matching_paths(paths)

      if matches.any?
        msg = "WARNING: Bolt is case sensitive when specifying a #{type}. Did you mean:\n"
        matches.each { |path| msg += "         #{path}\n" }
        @logger.warn msg
      end
    end

    def matching_paths(paths)
      Array(paths).map { |p| Dir.glob([p, casefold(p)]) }.flatten.uniq.reject { |p| Array(paths).include?(p) }
    end

    private def casefold(path)
      path.chars.map do |l|
        l =~ /[A-Za-z]/ ? "[#{l.upcase}#{l.downcase}]" : l
      end.join
    end

    # Etc::SC_OPEN_MAX is meaningless on windows, not defined in PE Jruby and not available
    # on some platforms. This method holds the logic to decide whether or not to even consider it.
    def sc_open_max_available?
      !Bolt::Util.windows? && defined?(Etc::SC_OPEN_MAX) && Etc.sysconf(Etc::SC_OPEN_MAX)
    end

    def default_concurrency
      @default_concurrency ||= if !sc_open_max_available? || Etc.sysconf(Etc::SC_OPEN_MAX) >= 300
                                 DEFAULT_DEFAULT_CONCURRENCY
                               else
                                 Etc.sysconf(Etc::SC_OPEN_MAX) / 7
                               end
    end
  end
end
