# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details.
# frozen_string_literal: true

require 'yaml'
require 'fileutils'

require 'contrast/config'
require 'contrast/utils/object_share'
require 'contrast/components/agent'
require 'contrast/components/api'
require 'contrast/components/app_context'
require 'contrast/components/scope'
require 'contrast/components/inventory'
require 'contrast/components/protect'
require 'contrast/components/assess'
require 'contrast/components/config/sources'
require 'contrast/config/server_configuration'
require 'contrast/config/configuration_files'
require 'contrast/utils/hash_utils'

module Contrast
  # This is how we read in the local settings for the Agent, both ENV/ CMD line
  # and from properties files, in order to determine which settings, if any,
  # the user has overridden.
  class Configuration
    extend Forwardable

    include Contrast::Components::Scope::InstanceMethods
    extend Contrast::Components::Scope::InstanceMethods

    include Contrast::Config::BaseConfiguration

    attr_reader :default_name

    # @return [Contrast::Components::Api::Interface]
    attr_writer :api
    # @return [Contrast::Components::Agent::Interface]
    attr_writer :agent
    # @return [Contrast::Components::AppContext::Interface]
    attr_writer :application
    # @return [Contrast::Config::ServerConfiguration]
    attr_writer :server
    # @return [Contrast::Components::Assess::Interface]
    attr_writer :assess
    # @return [Contrast::Components::Inventory::Interface]
    attr_writer :inventory
    # @return [Contrast::Components::Protect::Interface]
    attr_writer :protect
    # @return [Boolean, nil]
    attr_accessor :enable
    # @return [Hash]
    attr_accessor :loaded_config
    # @return [Contrast::Config::Sources]
    attr_accessor :sources
    # @return [String,nil]
    attr_reader :config_file

    CONTRAST_ENV_MARKER = 'CONTRAST__'
    DEFAULT_YAML_PATH  = 'contrast_security.yaml'
    MILLISECOND_MARKER = '_ms'
    CONVERSION = {}.cs__freeze
    # Precedence of paths, shift if config file values needs to go up the chain.
    CONFIG_BASE_PATHS = %w[./ config/ /etc/contrast/ruby/ /etc/contrast/ /etc/].cs__freeze
    KEYS_TO_REDACT = %i[api_key url service_key user_name].cs__freeze
    REDACTED = '**REDACTED**'
    EFFECTIVE_REDACTED = '****'

    DEPRECATED_PROPERTIES = %w[
      CONTRAST__AGENT__SERVICE__ENABLE CONTRAST__AGENT__SERVICE__LOGGER__LEVEL
      CONTRAST__AGENT__SERVICE__LOGGER__PATH CONTRAST__AGENT__SERVICE__LOGGER__STDOUT
    ].cs__freeze

    def initialize cli_options = nil, default_name = DEFAULT_YAML_PATH
      @default_name = default_name

      # Load config_kv from file
      config_kv = Contrast::Utils::HashUtils.deep_symbolize_all_keys(load_config)

      # Load cli options from env
      cli_options ||= cli_to_hash
      config_kv = Contrast::Utils::HashUtils.precedence_merge(config_kv, cli_options)
      update_sources_from_cli(cli_options)

      # Some in-flight rewrites to maintain backwards compatibility
      config_kv = update_prop_keys(config_kv)
      @sources = Contrast::Components::Config::Sources.new(source_file_extensions)
      @loaded_config = config_kv

      # requires loaded_config:
      create_config_components
    end

    # Get a loggable YAML format of this configuration
    # @return [String] the current active configuration of the Agent,
    #   represented as a YAML string
    def loggable
      convert_to_hash.to_yaml
    end

    # @return [Hash] map of all extensions for each config key.
    def source_file_extensions
      @_source_file_extensions ||= {}
    end

    # @return [Contrast::Components::Api::Interface]
    def api
      @api ||= Contrast::Components::Api::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Components::Agent::Interface]
    def agent
      @agent ||= Contrast::Components::Agent::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Components::AppContext::Interface]
    def application
      @application ||= Contrast::Components::AppContext::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Config::ServerConfiguration]
    def server
      @server ||= Contrast::Config::ServerConfiguration.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Components::Assess::Interface]
    def assess
      @assess ||= Contrast::Components::Settings::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Components::Inventory::Interface]
    def inventory
      @inventory ||= Contrast::Components::Inventory::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # @return [Contrast::Components::Protect::Interface]
    def protect
      @protect ||= Contrast::Components::Protect::Interface.new # rubocop:disable Naming/MemoizedInstanceVariableName
    end

    # Base paths to check for the contrast configuration file, sorted by
    # reverse order of precedence (first is most important).
    def configuration_paths
      @_configuration_paths ||= begin
        basename = default_name.split('.')[0]
        # Order of extensions comes from here:
        extensions = Contrast::Components::Config::Sources::APP_CONFIGURATION_EXTENSIONS

        paths = []
        # Environment paths takes precedence here. Look first through them.
        config_path = ENV.fetch('CONTRAST_CONFIG_PATH', nil)
        security_path = ENV.fetch('CONTRAST_SECURITY_CONFIG', nil)
        paths << config_path if config_path
        paths << security_path if security_path

        extensions.each do |ext|
          places = CONFIG_BASE_PATHS.product(["#{ basename }.#{ ext }"])
          paths += places.map!(&:join)
        end
        paths
      end
    end

    # List of all read configuration files.
    #
    # @return [Contrast::Config::ConfigurationFiles] of paths
    def origin
      @_origin ||= Contrast::Config::ConfigurationFiles.new
    end

    protected

    def yaml_to_hash path
      if path && File.readable?(path)
        begin
          yaml = File.read(path)
          yaml = ERB.new(yaml).result if defined?(ERB)
          return YAML.safe_load(yaml)
        rescue Psych::Exception => e
          log_yaml_parse_error(path, e)
        rescue RuntimeError => e
          puts("WARN: Unable to load configuration. #{ e }; path: #{ path }, pwd: #{ Dir.pwd }")
        end
      end

      {}
    end

    # TODO: RUBY-546 move utility methods to auxiliary classes

    # Read through all the paths we know config may live. Merge all values from all files found.
    # Priority is given to yaml over yml. If same keys are found on two configs with different extensions,
    # the Agent will read first from the yaml file and ignore the values for same key on the yml file.
    #
    # @return config [Hash] All source configurations from files.
    def load_config
      config = {}
      @_source_file_extensions = {}
      configuration_paths.each do |path|
        next unless File.exist?(path)

        unless File.readable?(path)
          log_file_read_error(path)
          next
        end
        origin.add_source_file(path, (yaml_to_hash(path) || {}))
      end
      # Legacy usage: Assign main configuration file for reference.
      @config_file = origin.main_file
      # merge all settings keeping the top yaml files values as priority.
      # If in top file a key exists it's value won't be changed if same key has different value in one
      # of the other config files. Only unique values will be taken in consideration.w
      # precedence of paths: see Contrast::Configuration::CONFIG_BASE_PATHS
      extensions_maps = []
      origin.source_files.each do |file|
        config = Contrast::Utils::HashUtils.precedence_merge(config, file.values)
        # assign source values extentions:
        extensions_maps << assign_source_to(Contrast::Utils::HashUtils.deep_symbolize_all_keys(file.values), file.path)
      end

      # merge all origin paths to be used as extension classification to preserve the precedence of config files:
      extensions_maps.each do |path|
        @_source_file_extensions = Contrast::Utils::HashUtils.precedence_merge!(@_source_file_extensions, path)
      end

      config
    end

    # We're updating properties loaded from the configuration files to match the new agreed upon standard configuration
    # names, so that one file works for all agents
    def update_prop_keys config
      CONVERSION.each_pair do |old_method, new_method|
        # See if the old value was set and needs to be translated
        deprecated_keys = old_method.split('.').map(&:to_sym)
        old_value = config
        deprecated_keys.each do |key|
          old_value = old_value[key]
          break if old_value.nil?
        end
        next if old_value.nil? # have to account for literal false

        log_deprecated_property(old_method, new_method)
        new_keys = new_method.split('.').map(&:to_sym)
        # We changed the seconds values into ms values. Multiply them accordingly
        old_value = old_value.to_i * 1000 if new_method.end_with?(MILLISECOND_MARKER)
        new_value = config
        replace_props(new_keys, new_value, old_value)
      end

      config
    end

    private

    # Creates and updates the config components with the loaded config values.
    def create_config_components
      @api = Contrast::Components::Api::Interface.new(loaded_config[:api])
      @enable = loaded_config[:enable]
      @agent = Contrast::Components::Agent::Interface.new(loaded_config[:agent])
      @application = Contrast::Components::AppContext::Interface.new(loaded_config[:application])
      @server = Contrast::Config::ServerConfiguration.new(loaded_config[:server])
      @assess = Contrast::Components::Assess::Interface.new(loaded_config[:assess])
      @inventory = Contrast::Components::Inventory::Interface.new(loaded_config[:inventory])
      @protect = Contrast::Components::Protect::Interface.new(loaded_config[:protect])
    end

    # We cannot use all access components at this point, unfortunately, as they
    # may not have been initialized. Instead, we need to access the logger
    # directly.
    def logger
      @_logger ||= (Contrast::Logger::Log.instance.logger if defined?(Contrast::Logger::Log))
    end

    # When we fail to parse a configuration because it is misformatted, log an
    # appropriate message based on the Agent Onboarding specification
    def log_yaml_parse_error path, exception
      hash = { path: path, pwd: Dir.pwd }
      if exception.is_a?(Psych::SyntaxError)
        hash[:context] = exception.context
        hash[:column] = exception.column
        hash[:line] = exception.line
        hash[:offset] = exception.offset
        hash[:problem] = exception.problem
      end

      if logger
        logger.warn('YAML validator found an error', hash)
      else
        puts("CONTRAST - WARN: YAML validator found an error. #{ hash.inspect }")
      end
    end

    def log_file_read_error path
      if logger
        logger.warn('Configuration file is not readable by current user', path: path)
      else
        puts("CONTRAST - WARN: Configuration file is not readable by current user; path: #{ path }")
      end
    end

    def log_deprecated_property old_method, new_method
      if logger
        logger.warn('Deprecated property in use', old_method: old_method, new_method: new_method)
      else
        puts("CONTRAST - WARN: Deprecated property in use; old_method: #{ old_method }, new_method: #{ new_method }")
      end
    end

    # Convert this entire configuration into a hash, walking down the entries
    # in the thing to convert and setting them in the given hash. For now, this
    # logs every possible key, whether set or not. If we want to change that
    # behavior, we can skip adding keys to the hash if the value is nil, blank,
    #
    # @param hash [Hash] the hash to populate
    # @param convert [Contrast::Config::BaseConfiguration, Object] the level of
    #   configuration from which to convert. Note that at least one top level
    #   Contrast::Config::BaseConfiguration is required for anything to be set
    #   in the hash
    # @return [Hash, Object] the leaf of each
    #   Contrast::Config::BaseConfiguration will be returned in the N > 0 steps
    #   the Hash will be returned at the end of the 0 level
    def convert_to_hash convert = self, hash = {}
      case convert
      when Contrast::Config::BaseConfiguration
        # to_hash returns @configuration_map
        convert.to_contrast_hash.each_key do |key|
          # change '-' to '_' for ProtectRulesConfiguration
          hash[key] = convert_to_hash(convert.send(key.tr('-', '_').to_sym), {})
          hash[key] = REDACTED if redactable?(key)
        end
        hash
      else
        convert
      end
    end

    def replace_props new_keys, new_value, old_value
      idx = 0
      end_idx = new_keys.length - 1
      while idx < new_keys.length
        new_key = new_keys[idx].to_sym
        if idx == end_idx
          new_value[new_key] = old_value if new_value[new_key].nil?
        else
          new_value = {} if new_value.nil?
          new_value[new_key] = {} if new_value[new_key].nil?
          new_value = new_value[new_key]
        end
        idx += 1
      end
    end

    # Check if keys with sensitive data needs to be
    # redacted.
    #
    # @param key [Symbol] key to check
    # @return[Boolean] true | false
    def redactable? key
      KEYS_TO_REDACT.include?(key.to_sym)
    end

    def assign_source_to hash, source = Contrast::Components::Config::Sources::APP_CONFIGURATION_FILE
      hash.transform_values do |value|
        if value.is_a?(Hash)
          assign_source_to(value, source)
        else
          source
        end
      end
    end

    # Update the source mapping to reflect the cli values passed. Using raw string rather than path values.
    #
    # @param cli_options[Hash<Symbol, String>]
    def update_sources_from_cli cli_options
      @_source_file_extensions = Contrast::Utils::HashUtils.
          precedence_merge(assign_source_to(cli_options,
                                            Contrast::Components::Config::Sources::COMMAND_LINE),
                           @_source_file_extensions)
    end

    # Find all the set Contrast environment variables and cast them to their hash form. Keys will be split on __ and
    # converted to symbols to match parsing of the YAML file
    #
    # @return [Hash<Symbol, (Hash, String)>]
    def cli_to_hash
      cli_options ||= ENV.select do |name, _value|
        name.to_s.start_with?(CONTRAST_ENV_MARKER) && !DEPRECATED_PROPERTIES.include?(name.to_s)
      end

      converted = {}
      cli_options&.each do |key, value|
        # Split the env key into path components
        path = key.to_s.split(Contrast::Utils::ObjectShare::DOUBLE_UNDERSCORE)
        # Remove the `CONTRAST` start
        path&.shift
        # Convert it to hash form, with lowercase symbol keys
        as_hash = path&.reverse&.reduce(value) do |assigned_value, path_segment|
          { path_segment.downcase.to_sym => assigned_value }
        end
        # And join it w/ the parsed keys
        Contrast::Utils::HashUtils.precedence_merge!(converted, as_hash)
      end
      converted
    end
  end
end