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

require 'contrast/utils/env_configuration_item'
require 'ougai'
require 'contrast/configuration'

module Contrast
  module Components
    # This component encapsulates reference to the configuration file.
    # At the time of writing, the configuration file is a yaml file reflecting
    # the 'common agent configuration' specification.
    # 'Config' and 'configuration' are to be interpreted as referring
    # specifically to these files and specifications, they are not generic
    # terms for other avenues of user configuration.
    #
    # This component is responsible for...
    #  - encapsulating file access & concomitant error conditions
    #  - implementing validity checks with respect to the specification
    #  - memoizing/streamline field accesses
    #
    # Config fails fast.  if it's not valid, the agent should break, and
    # it should break LOUDLY.  Better to waste half an hour of the sysadmin's
    # time than to silently fail to deliver functionality.
    module Config
      CONTRAST_ENV_MARKER = 'CONTRAST__'
      CONTRAST_LOG = 'contrast_agent.log'
      CONTRAST_NAME = 'Contrast Agent'

      class Interface # :nodoc: # rubocop:disable Metrics/ClassLength
        SESSION_VARIABLES = 'Invalid configuration. '\
                            "Setting both application.session_id and application.session_metadata is not allowed.\n"
        API_URL = "Invalid configuration. Missing a required connection value 'url' is not set."
        API_KEY = "Invalid configuration. Missing a required connection value 'api_key' is not set."
        API_SERVICE_KEY = "Invalid configuration. Missing a required connection value 'service_tag' is not set."
        API_USERNAME = "Invalid configuration. Missing a required connection value 'user_name' is not set."
        def initialize
          build
        end

        # Basic logger for handling configuration validation logging
        # the file to log is determined by the default one or set
        # by the config file, if that configuration is found
        def proto_logger
          @_proto_logger ||= begin
            @_proto_logger = ::Ougai::Logger.new(logger_path || CONTRAST_LOG)
            @_proto_logger.progname = CONTRAST_NAME
            @_proto_logger.level = ::Ougai::Logging::Severity::WARN
            @_proto_logger.formatter = Contrast::Logger::Format.new
            @_proto_logger.formatter.datetime_format = '%Y-%m-%dT%H:%M:%S.%L%z'
            @_proto_logger
          end
        end

        def build
          @_valid = nil
          @config = Contrast::Configuration.new
          env_overrides
          validate
        rescue ArgumentError => e
          proto_logger.error('Configuration failed with error: ', e)
        end
        alias_method :rebuild, :build

        # @return [Contrast::Config::RootConfiguration]
        def root
          @config.root
        end

        def valid?
          @_valid = validate if @_valid.nil?
          @_valid
        end

        def invalid?
          !valid?
        end

        def loggable
          @config.loggable
        end

        # Typically, this would be accessed through Contrast::SETTINGS, but we're specifically checking for the user
        # provided value here rather than that echoed back by TeamServer.
        #
        # @return [String,nil] the value of the session id set in the configuration, or nil if unset
        def session_id
          root.application.session_id
        end

        # @return [String,nil] the value of the session metadata set in the configuration, or nil if unset
        def session_metadata
          root.application.session_metadata
        end

        private

        # The config has information about how to construct the logger. If the config is invalid, and you want to know
        # about it, then you have a circular dependency if you try to log it, so we use basic proto_logger to do this
        # job.
        def validate
          return false unless valid_session_metadata?

          valid_api?
        end

        # The use can set either the application's session id or session metadata or neither, but never both.
        #
        # @return [boolean]
        def valid_session_metadata?
          if !session_id&.empty? && !session_metadata&.empty?
            proto_logger.error(SESSION_VARIABLES)
            return false
          end
          true
        end

        # If the agent is to use the bypass to communicate with TeamServer directly, than it must have the
        # configuration values required for that connection.
        #
        # @return [boolean]
        def valid_api?
          return true unless bypass

          msg = []
          msg << API_URL unless api_url
          msg << API_KEY unless api_key
          msg << API_SERVICE_KEY unless api_service_key
          msg << API_USERNAME unless api_username
          msg.any? { |m| proto_logger.error(m) }
          msg.empty?
        end

        def env_overrides
          # For env variables resembling CONTRAST__WHATEVER__NESTED_VALUE
          # override raw.whatever.nested_value
          ENV.each do |env_key, env_value|
            next unless env_key.to_s.start_with?(CONTRAST_ENV_MARKER)

            config_item = Contrast::Utils::EnvConfigurationItem.new(env_key, env_value)
            assign_value_to_path_array(root, config_item.dot_path_array, config_item.value)
          end
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::API, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def api_url
          root.api.url
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::API, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def api_key
          root.api.api_key
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::API, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def api_service_key
          root.api.service_key
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::API, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def api_username
          root.api.user_name
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::API, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def bypass
          root.agent.service.bypass
        end

        # Typically, the following values would be accessed through Contrast::Components::AppContext
        # and Contrast::Components::Logger, but we're too early in the initialization of the Agent to use
        # that mechanism, so we look it up directly for ourselves.
        #
        # @return [String, nil]
        def logger_path
          root.agent.logger.path
        end

        # Assign the value from an ENV variable to the Contrast::Config::RootConfiguration object, when
        # appropriate.
        #
        # @return nil
        def assign_value_to_path_array current_level, dot_path_array, value
          dot_path_array[0...-1].each do |segment|
            segment = segment.tr('-', '_')
            current_level = current_level.send(segment) if current_level.cs__respond_to?(segment)
          end
          return unless current_level.nil? == false && current_level.cs__respond_to?(dot_path_array[-1])

          current_level.send("#{ dot_path_array[-1] }=", value)
        end
      end
    end
  end
end