module Vanity
  # This class holds the "how" Vanity operates. For the "what", please see
  # Vanity::Playground.
  class Configuration
    class MissingEnvironment < StandardError; end

    LEGACY_CONNECTION_KEY = :connection
    LEGACY_REDIS_CONFIG_FILE = "redis.yml"

    class << self
      private

      def default_logger # :nodoc:
        logger = Logger.new(STDOUT)
        logger.level = Logger::WARN
        logger
      end

      def default_on_datastore_error(error, klass, method, arguments) # :nodoc:
        log = "[#{Time.now.iso8601}]"
        log << " [vanity #{klass} #{method}]"
        log << " [#{error.message}]"
        log << " [#{arguments.join(' ')}]"
        Vanity.logger.error(log)
        nil
      end

      #
      # Filter all User-Agents that have 'bot', 'crawler', 'spider', URL.
      #
      def default_request_filter(request) # :nodoc:
        request &&
          request.env &&
          request.env["HTTP_USER_AGENT"] &&
          request.env["HTTP_USER_AGENT"].match(/(?:https?:\/\/)|(?:bot|spider|crawler)/i)
      end
    end

    DEFAULTS = {
      add_participant_route: "/vanity/add_participant",
      collecting: true,
      config_file: "vanity.yml",
      config_path: File.join(Pathname.new("."), "config"),
      environment: ENV["RACK_ENV"] || ENV["RAILS_ENV"] || "development",
      experiments_path: File.join(Pathname.new("."), "experiments"),
      failover_on_datastore_error: false,
      locales_path: File.expand_path(File.join(File.dirname(__FILE__), 'locales')),
      logger: default_logger,
      on_datastore_error: lambda { |error, klass, method, arguments|
        default_on_datastore_error(error, klass, method, arguments)
      },
      on_assignment: nil,
      request_filter: ->(request) { default_request_filter(request) },
      templates_path: File.expand_path(File.join(File.dirname(__FILE__), 'templates')),
      use_js: false,
      experiments_start_enabled: true,
      cookie_name: 'vanity_id',
      cookie_expires: 20 * 365 * 24 * 60 * 60, # 20 years, give or take.
      cookie_domain: nil,
      cookie_path: nil,
      cookie_secure: false,
      cookie_httponly: false,
    }.freeze

    # True if saving results to the datastore (participants and conversions).
    attr_writer :collecting

    # Path to load experiment files from.
    attr_writer :experiments_path

    # URL to the add_participant action.
    attr_writer :add_participant_route

    # Logger. The default logs to STDOUT.
    attr_writer :logger

    # -- Datastore graceful failover --

    # Turns on passing of errors to the Proc returned by #on_datastore_error.
    # Set `config.failover_on_datastore_error` to `true` to turn this on.
    #
    # @since 2.0.0
    attr_writer :failover_on_datastore_error

    # Must return a Proc that accepts as parameters: the thrown error, the
    # calling Class, the calling method, and an array of arguments passed to
    # the calling method. The return value is ignored.
    #
    # @example
    #    Proc.new do |error, klass, method, arguments|
    #      ...
    #    end
    #
    # The default implementation logs this information to Playground#logger.
    #
    # Set a custom action by calling config.on_datastore_error =
    # Proc.new { ... }.
    #
    # @since 2.0.0
    attr_writer :on_datastore_error

    # -- Blocking or ignoring visitors --

    # Must return a Proc that accepts as a parameter the request object, if
    # made available by the implement framework. The return value should be a
    # boolean whether to ignore the request. This is called only for the JS
    # callback action.
    #
    # @example
    #    Proc.new do |request|
    #      ...
    #    end
    #
    # The default implementation does a simple test of whether the request's
    # HTTP_USER_AGENT header contains a URI, or the words 'bot', 'crawler', or
    # 'spider' since well behaved bots typically include a reference URI in
    # their user agent strings. (Original idea:
    # http://stackoverflow.com/a/9285889.)
    #
    # Alternatively, one could filter an explicit list of IPs, add additional
    # user agent strings to filter, or any custom test. Set a custom filter
    # by calling config.request_filter = Proc.new { ... }.
    #
    # @since 2.0.0
    attr_writer :request_filter

    # Path to Vanity templates. Set this to override those in the gem.
    attr_writer :templates_path

    # Path to Vanity locales. Set this to override those in the gem.
    attr_writer :locales_path

    # -- Robot Detection --

    # Call to indicate that participants should be added via js. This helps
    # keep robots from participating in the A/B test and skewing results.
    #
    # If you want to use this:
    # - Add <%= vanity_js %> to any page that needs uses an ab_test. vanity_js
    #   needs to be included after your call to ab_test so that it knows which
    #   version of the experiment the participant is a member of. The helper
    #   will render nothing if the there are no ab_tests running on the current
    #   page, so adding vanity_js to the bottom of your layouts is a good
    #   option. Keep in mind that if you set config.use_js = true and don't include
    #   vanity_js in your view no participants will be recorded.
    #
    # Note that a custom JS callback path can be set using:
    # - Set config.add_participant_route = '/path/to/vanity/action',
    #   this should point to the add_participant path that is added with
    #   Vanity::Rails::Dashboard, make sure that this action is available
    #   to all users.
    attr_writer :use_js
    # Uses ./config by default.
    attr_writer :config_path
    # By default the vanity.yml file in the config_path variable. Variables
    # scoped under the key for the current environment are extracted for the
    # connection parameters. If there is no config/vanity.yml file, tries the
    # configuration from config/redis.yml.
    attr_writer :config_file
    # In order of precedence, RACK_ENV, RAILS_ENV or `development`.
    attr_writer :environment
    # By default experiments start enabled. If you want experiments to be
    # explicitly enabled after a production release, then set to false.
    attr_writer :experiments_start_enabled

    # Cookie name. By default 'vanity_id'
    attr_writer :cookie_name

    # Cookie duration. By default 20 years.
    attr_writer :cookie_expires

    # Cookie domain.  By default nil.  If domain is nil then the domain from
    # Rails.application.config.session_options[:domain] will be substituted.
    attr_writer :cookie_domain

    # Cookie path. By default nil.
    attr_writer :cookie_path

    # Cookie secure. If true, cookie will only be transmitted to SSL pages. By default false.
    attr_writer :cookie_secure

    # Cookie path. If true, cookie will not be available to JS. By default false.
    attr_writer :cookie_httponly

    # Default callback on assigment
    attr_writer :on_assignment

    # We independently list each attr_accessor to includes docs, otherwise
    # something like DEFAULTS.each { |key, value| attr_accessor key } would be
    # shorter.
    DEFAULTS.each do |default, _value|
      define_method default do
        self[default]
      end
    end

    def [](arg)
      if instance_variable_defined?("@#{arg}")
        instance_variable_get("@#{arg}")
      else
        DEFAULTS[arg]
      end
    end

    def setup_locales
      locales = Dir[File.join(locales_path, '*.{rb,yml}')]
      I18n.load_path += locales
    end

    # @return nil or a hash of symbolized keys for connection settings
    def connection_params(file_name = nil)
      file_name ||= config_file
      file_path = File.join(config_path, file_name)

      if File.exist?(file_path) # rubocop:todo Style/GuardClause
        config = SafeYAML.load(ERB.new(File.read(file_path)).result)
        config ||= {}
        params_for_environment = config[environment.to_s]

        raise MissingEnvironment, "No configuration for #{environment}" unless params_for_environment

        # Symbolize keys if it's a hash.
        if params_for_environment.respond_to?(:inject)
          params_for_environment.each_with_object({}) do |kv, h|
            h[kv.first.to_sym] = kv.last
          end
        else
          params_for_environment
        end
      end
    end

    # @deprecated
    def connection_url
      connection_config = connection_params

      return unless connection_config && connection_config.respond_to?(:has_key?)

      connection_url = connection_config[LEGACY_CONNECTION_KEY]

      if connection_url # rubocop:todo Style/GuardClause
        logger.warn('Deprecated: Please specify connection urls using the `url` key with a protocol prefix instead of `connection`. This fallback will be removed in a future version.')

        # Legacy lack of protocol handling
        if /^\w+:/.match?(connection_url)
          connection_url
        else
          "redis://" + connection_url
        end
      end
    end

    # @deprecated
    def redis_url_from_file
      connection_url = connection_params(LEGACY_REDIS_CONFIG_FILE)

      if connection_url # rubocop:todo Style/GuardClause
        logger.warn('Deprecated: Please specify the vanity config file, the default fallback to "config/redis.yml" may be removed in a future version.')

        if /^\w+:/.match?(connection_url)
          connection_url
        else
          "redis://" + connection_url
        end
      end
    end
  end
end
