# encoding: utf-8
require 'fedux_org_stdlib/require_files'
require 'fedux_org_stdlib/app_config/exceptions'
require 'fedux_org_stdlib/process_environment'
require 'fedux_org_stdlib/core_ext/array/list'
require 'fedux_org_stdlib/core_ext/hash/list'
require 'fedux_org_stdlib/logging/logger'
require_library %w(json psych active_support/core_ext/string/inflections set active_support/core_ext/hash/slice active_support/core_ext/object/blank active_support/core_ext/hash/keys)

module FeduxOrgStdlib
  # This class makes a config file available as an object. The config file
  # needs to be `YAML` by default. It is read by `Psych` and converted to a
  # hash. If you chose to use a different file format: Each config file needs
  # to translatable to a hash or another data structure which responds to
  # `[]` by the given `config_engine`. If no suitable config file can be found
  # the config uses only the defined defaults within the class.
  #
  # By default it will look for a suitable config file in the given order:
  #
  # 1. $HOME/.config/<application_name>/<config_file>.yaml
  # 2. $HOME/.<application_name>/<config_file>.yaml
  # 2. $HOME/.<config_file>.yaml
  # 2. $HOME/.<config_file>rc
  # 3. /etc/.<application_name>/<config_file>.yaml
  #
  # Please keep in mind
  #
  # * application_name: Module of your class, e.g. "MyApplication" becomes
  #   "my_application"
  # * config_file: Pluarized name of your class and "Config" strip
  #   off, e.g "ClientConfig" becomes "clients.yaml" (mind the pluralized name)
  #
  # Most conventions defined by me are implemented as separate methods. If one convention
  # is not suitable for your use case, just overwrite the method.
  #
  # If you prefer to use a different path to the config file or name of the
  # config file one of the following methods needs to be overwritten:
  #
  # * config_file
  # * config_name
  # * application_name
  #
  # If you want the class to look for your config file at a different place
  # overwrite the following method
  #
  # * allowed_config_file_paths
  #
  # Below you find some examples for the usage of the class:
  #
  # @example Create config with one writer and reader
  #   module MyApplication
  #     class ClientConfig < AppConfig
  #       # 'data1' is the default for option1
  #       # if you create a file
  #       option :option1, 'data1'
  #     end
  #   end
  #
  # @example Create config with a reader only
  #   module MyApplication
  #     class ClientConfig < AppConfig
  #       # 'data1' is the default for option1
  #       # if you create a file
  #       option_reader :option1, 'data1'
  #     end
  #   end
  #
  # @example Config yaml file for the classes above: clients.yaml
  #   ---
  #   option1: 'data2'
  class AppConfig
    class << self
      # @api private
      def known_options
        @options ||= Set.new
      end

      # Get access to process environment
      #
      # This might be handy to define default options
      #
      # @example Get variable
      #
      #   process_environment.fetch('HOME')
      #   # => ENV['HOME']
      def process_environment
        @process_environment ||= ProcessEnvironment.new
      end

      # Define a reader for option
      #
      # @param [Symbol] option
      #   Name of option
      #
      # @param [Object] default_value
      #   The default value of this option
      def option_reader(option, default_value)
        option =  option.to_sym

        fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? option

        define_method option do
          _config.fetch(option, default_value)
        end

        known_options << option
      end

      # Define a writer for option
      #
      # Please make sure that you define a reader as well. Otherwise you cannot
      # access the option. Under normal conditions it does not make sense to
      # use this method.
      #
      # @api private
      #
      # @param [Symbol] option
      #   Name of option
      #
      # @raise [Exceptions::ConfigLocked]
      #   If one tries to modified a locked config
      def option_writer(option)
        fail Exceptions::OptionNameForbidden, JSON.dump(option: option) if _reserved_key_words.include? "#{option}=".to_sym

        define_method "#{option}=".to_sym do |value|
          begin
            _config[option.to_sym] = value
          rescue RuntimeError
            raise Exceptions::ConfigLocked
          end
        end

        known_options << option
      end

      # Define a writer and a reader for option
      #
      # @param [Symbol] option
      #   Name of option
      #
      # @param [Object] default_value
      #   Default value of option
      def option(option, default_value)
        option_reader(option, default_value)
        option_writer(option)
      end
    end

    private

    # Holds the config
    # rubocop:disable Style/TrivialAccessors
    def _config
      @__config
    end
    # rubocop:enable Style/TrivialAccessors

    public

    # Create a new instance of config
    #
    # It tries to find a suitable configuration file. If it doesn't find one
    # the config is empty and uses the defaults defined within a config class
    #
    # @param [String] file
    #   Path where config file is stored. The file will be read by the
    #   `config_engine`.
    #
    # @param [Object] config_engine (Psych)
    #   The engine to read config file
    #
    # @param [true, false] check_unknown_options
    #   Should a warning be put to stdout if there are unknown options in yaml
    #   config file
    #
    # @raise [Exceptions::ConfigFileNotReadable]
    #   If an avaiable config file could not be read by the config engine
    #
    # @return [AppConfig]
    #   The config instance. If the resulting data structure created by the
    #   config_engine does not respond to `:[]` an empty config object will be
    #   created.

    attr_reader :file, :logger, :config_engine, :check_unknown_options, :working_directory, :safe

    def initialize(
      file: nil,
      config_engine: Psych,
      logger: FeduxOrgStdlib::Logging::Logger.new,
      check_unknown_options: true,
      working_directory: Dir.getwd,
      safe: true
    )
      @logger                = logger
      @file                  = file
      @config_engine         = config_engine
      @check_unknown_options = check_unknown_options
      @working_directory     = working_directory
      @safe                  = safe

      detect_file unless @file
      load_config
    end

    # Reload from already found config file
    def reload
      load_config
    end

    # Redected configuration file and reload config afterwards
    def redetect
      detect_file
      load_config
    end

    # Show known options for configuration
    def known_options
      self.class.known_options
    end

    # Lock the configuration
    def lock
      _config.freeze
    end

    # Output a string presentation of the configuration
    #
    # @return [String]
    #   An formatted version of the configuration
    def to_s
      # header 'length' = 6 letters
      length = self.class.known_options.reduce(6) { |a, e| e.size > a ? e.size : a }

      result = []
      result << format("%#{length}s | %s", 'option', 'value')
      result << format('%s + %s', '-' * length, '-' * 80)

      self.class.known_options.each do |o|
        value = public_send(o)

        value = if value == false
                  Array(value)
                elsif value.blank?
                  Array('is undefined')
                elsif value.is_a?(Hash) || value.is_a?(Array)
                  value
                else
                  Array(value)
                end

        result << format("%#{length}s | %s", o, value.to_list)
      end

      result.join("\n")
    end

    # Return the path to the preferred configuration file
    # @return [String]
    #   The path to the preferred configuration file
    def preferred_configuration_file
      _allowed_config_file_paths.first
    end

    # Clear configuration
    def clear
      @__config = {}
    end

    # Return configuration resetted to defaults
    def defaults
      config = self.dup
      config.clear

      config
    end

    private

    def detect_file
      @file = _available_config_file
    end

    def load_config
      unless file
        logger.debug "No configuration file found at #{_allowed_config_file_paths.to_list}, therefor I'm going to use an empty config object instead."
        @__config = {}

        return
      end

      begin
        yaml = if safe
                 config_engine.safe_load(File.read(file), [Symbol, Regexp])
               else
                 config_engine.load(File.read(file))
               end
      rescue StandardError => e
        raise Exceptions::ConfigFileNotReadable, JSON.dump(message: e.message, file: file)
      end

      if yaml.blank?
        @__config = {}
      elsif yaml.is_a? Hash
        yaml = yaml.deep_symbolize_keys

        yaml_withknown_options = yaml.deep_symbolize_keys.slice(*known_options)
        unknown_options = yaml.keys - yaml_withknown_options.keys

        logger.warn "Unknown config options #{(unknown_options).to_list} in config file #{file} detected. Please define them in your config class or remove the entries in your config file or disable check via `check_unknown_options: false` to get rid of this warning." unless unknown_options.blank? && check_unknown_options == true

        @__config = Hash(yaml_withknown_options)
      else
        logger.warn "There seems to be a problem transforming config file \"#{file}\" to a hash, therefor I will use an empty config object."
        @__config = {}
      end
    end

    # The name of the config file
    #
    # @return [String]
    #   The name of the config file. It defaults to `<config_name>.yaml`. If
    #   you want to use a different file name you need to overwrite this
    #   method.
    def _config_file
      "#{_config_name}#{_config_file_suffix}"
    end

    # The suffix of the config file
    #
    # @return [String]
    #   The suffix of the config file
    def _config_file_suffix
      '.yaml'
    end

    # The base name of the config
    #
    # @return [String]
    #   This one returns the base name of the config file (without the file
    #   extension). It uses the class name of the config class
    #
    # @example Determine the base name of the config
    #
    #   class ClientConfig; end
    #
    # This will result in `client` as base name for the config file.
    def _config_name
      unless (name = _class_name.sub(/Config/, '').underscore.pluralize).blank?
        return name
      end

      fail Exceptions::ClassNameIsMissing, JSON.dump(klass: _class_name)
    end

    # The name of your application
    #
    # @return [String]
    #  This will strip of the class part of fully qualified class name and
    #  converted it to a path.
    #
    # @example Determine application name
    #
    #   class MyApplication::MyConfig; end
    #
    # This will be converted to
    #
    #    my_application
    def _application_name
      _module_name.underscore
    end

    # The paths where to look for the config file
    #
    # @return [Array]
    #   A list of paths where the config object should look for its config
    #   file.
    def _allowed_config_file_paths
      [
        ::File.expand_path(::File.join('~', '.config', _application_name, _config_file)),
        ::File.expand_path(::File.join('~', format('.%s', _application_name), _config_file)),
        ::File.expand_path(::File.join('~', format('.%s', _config_file))),
        ::File.expand_path(::File.join('~', format('.%src', _config_name))),
        ::File.expand_path(::File.join('/etc', _application_name, _config_file)),
        ::File.expand_path(::File.join(working_directory, _config_file))
      ]
    end

    def _class_name
      self.class.name.to_s.demodulize
    end

    def _module_name
      self.class.to_s.deconstantize
    end

    def _available_config_file
      _allowed_config_file_paths.find { |f| ::File.exist? f }
    end

    def self._reserved_key_words
      (methods | instance_methods | private_methods | private_instance_methods) - (Class.methods | Class.private_methods) | [:to_s]
    end

    def method_missing(*args, &block)
      $stderr.puts "Please check if you have defined an option for #{args.first}."

      super
    end
  end
end