module TrustyCms
  class << self
    def config_definitions
      @config_definitions ||= {}
    end

    def config_definitions=(definitions)
      @config_definitions = definitions
    end
  end

  class Config < ActiveRecord::Base
    require 'trusty_cms/config/definition'
    #
    # The TrustyCms.config model class is stored in the database (and cached) but emulates a hash
    # with simple bracket methods that allow you to get and set values like so:
    #
    #   TrustyCms.config['setting.name'] = 'value'
    #   TrustyCms.config['setting.name'] #=> "value"
    #
    # Config entries can be used freely as general-purpose global variables unless a definition
    # has been given for that key, in which case restrictions and defaults may apply. The restrictions
    # can take the form of validations, requirements, permissions or permitted options. They are
    # declared by calling TrustyCms::Config#define:
    #
    #   # setting must be either 'foo', 'bar' or 'blank'
    #   define('admin.name', :select_from => ['foo', 'bar'])
    #
    #   # setting is (and must be) chosen from the names of currently available layouts
    #   define('shop.layout', :select_from => lambda { Layout.all.map{|l| [l.name,l.id]} }, :alow_blank => false)
    #
    #   # setting cannot be changed at runtime
    #   define('setting.important', :default => "something", :allow_change => false)
    #
    # Which almost always happens in a block like this:
    #
    #   TrustyCms.config do |config|
    #     config.namespace('user', :allow_change => true) do |user|
    #       user.define 'allow_password_reset?', :default => true
    #     end
    #   end
    #
    # or in an extension. TrustyCms currently defines the following settings and makes them editable by
    # admin users on the site configuration page:
    #
    # admin.title               :: the title of the admin system
    # admin.subtitle            :: the subtitle of the admin system
    # defaults.page.parts       :: a comma separated list of default page parts
    # defaults.page.status      :: a string representation of the default page status
    # defaults.page.filter      :: the default filter to use on new page parts
    # defaults.page.fields      :: a comma separated list of the default page fields
    # defaults.snippet.filter   :: the default filter to use on new snippets
    # dev.host                  :: the hostname where draft pages are viewable
    # local.timezone            :: the timezone name (`rake -D time` for full list)
    #                              used to correct displayed times
    # page.edit.published_date? :: when true, shows the datetime selector
    #                              for published date on the page edit screen
    #
    # Helper methods are defined in ConfigurationHelper that will display config entry values
    # or edit fields:
    #
    #   # to display label and value, where label comes from looking up the config key in the active locale
    #   show_setting('admin.name')
    #
    #   # to display an appropriate checkbox, text field or select box with label as above:
    #   edit_setting('admin.name)
    #

    self.table_name = 'config'
    after_save :update_cache
    attr_reader :definition

    class ConfigError < RuntimeError; end

    class << self
      def [](key)
        if database_exists?
          if table_exists?
            unless TrustyCms::Config.cache_file_exists?
              TrustyCms::Config.ensure_cache_file
              TrustyCms::Config.initialize_cache
            end
            TrustyCms::Config.initialize_cache if TrustyCms::Config.stale_cache?
            Rails.cache.read('TrustyCms::Config')[key]
          end
        end
      end

      def []=(key, value)
        if database_exists?
          if table_exists?
            setting = where(key: key).first_or_initialize
            setting.value = value
          end
        end
      end

      def database_exists?
        ActiveRecord::Base.connection
      rescue ActiveRecord::NoDatabaseError
        false
      else
        true
      end

      def to_hash
        Hash[*all.map { |pair| [pair.key, pair.value] }.flatten]
      end

      def initialize_cache
        TrustyCms::Config.ensure_cache_file
        Rails.cache.write('TrustyCms::Config', TrustyCms::Config.to_hash)
        Rails.cache.write('TrustyCms.cache_mtime', File.mtime(cache_file))
        Rails.cache.silence!
      end

      def cache_file_exists?
        File.file?(cache_file)
      end

      def stale_cache?
        return true unless TrustyCms::Config.cache_file_exists?

        Rails.cache.read('TrustyCms.cache_mtime') != File.mtime(cache_file)
      end

      def ensure_cache_file
        FileUtils.mkpath(cache_path)
        FileUtils.touch(cache_file)
      end

      def cache_path
        "#{Rails.root}/tmp"
      end

      def cache_file
        File.join(cache_path, 'trusty_config_cache.txt')
      end

      def site_settings
        @site_settings ||= %w{site.title site.host dev.host local.timezone}
      end

      def default_settings
        @default_settings ||= %w{defaults.locale defaults.page.filter defaults.page.parts defaults.page.fields defaults.page.status}
      end

      def user_settings
        @user_settings ||= ['user.allow_password_reset?']
      end

      # A convenient drying method for specifying a prefix and options common to several settings.
      #
      #   TrustyCms.config do |config|
      #     config.namespace('secret', :allow_display => false) do |secret|
      #       secret.define('identity', :default => 'batman')      # defines 'secret.identity'
      #       secret.define('lair', :default => 'batcave')         # defines 'secret.lair'
      #       secret.define('longing', :default => 'vindication')  # defines 'secret.longing'
      #     end
      #   end
      #
      def namespace(prefix, options = {}, &block)
        prefix = [options[:prefix], prefix].join('.') if options[:prefix]
        with_options(options.merge(prefix: prefix), &block)
      end

      # Declares a setting definition that will constrain and support the use of a particular config entry.
      #
      #   define('setting.key', options)
      #
      # Can take several options:
      # * :default is the value that will be placed in the database if none has been set already
      # * :type can be :string, :boolean or :integer. Note that all settings whose key ends in ? are considered boolean.
      # * :select_from should be a list or hash suitable for passing to options_for_select, or a block that will return such a list at runtime
      # * :validate_with should be a block that will receive a value and return true or false. Validations are also implied by type or select_from.
      # * :allow_blank should be false if the config item must not be blank or nil
      # * :allow_change should be false if the config item can only be set, not changed. Add a default to specify an unchanging config entry.
      # * :allow_display should be false if the config item should not be showable in radius tags
      #
      #   TrustyCms.config do |config|
      #     config.define 'defaults.locale', :select_from => lambda { TrustyCms::AvailableLocales.locales }, :allow_blank => true
      #     config.define 'defaults.page.parts', :default => "Body,Extended"
      #     ...
      #   end
      #
      # It's also possible to reuse a definition by passing it to define:
      #
      #   choose_layout = TrustyCms::Config::Definition.new(:select_from => lambda {Layout.all.map{|l| [l.name, l.d]}})
      #   define "my.layout", choose_layout
      #   define "your.layout", choose_layout
      #
      # but at the moment that's only done in testing.
      #
      def define(key, options = {})
        called_from = caller.grep(/\/initializers\//).first
        if options.is_a? TrustyCms::Config::Definition
          definition = options
        else
          key = [options[:prefix], key].join('.') if options[:prefix]
        end

        definition ||= TrustyCms::Config::Definition.new(options.merge(definer: called_from))
        definitions[key] = definition

        if self[key].nil? && !definition.default.nil?
          begin
            self[key] = definition.default
          rescue ActiveRecord::RecordInvalid
            raise LoadError, "Default configuration invalid: value '#{definition.default}' is not allowed for '#{key}'"
          end
        end
      end

      def definitions
        TrustyCms.config_definitions
      end

      def definition_for(key)
        definitions[key] ||= TrustyCms::Config::Definition.new(empty: true)
      end
    end

    # The usual way to use a config item:
    #
    #    TrustyCms.config['key'] = value
    #
    # is equivalent to this:
    #
    #   TrustyCms::Config.find_or_create_by_key('key').value = value
    #
    # Calling value= also applies any validations and restrictions that are found in the associated definition.
    # so this will raise a ConfigError if you try to change a protected config entry or a RecordInvalid if you
    # set a value that is not among those permitted.
    #
    def value=(param)
      newvalue = param.to_s
      if newvalue != self[:value]
        raise ConfigError, "#{key} cannot be changed" unless settable? || self[:value].blank?

        self[:value] = if boolean?
                         newvalue == '1' || newvalue == 'true' ? 'true' : 'false'
                       else
                         newvalue
                       end
        save!
      end
      self[:value]
    end

    # Requesting a config item:
    #
    #    key = TrustyCms.config['key']
    #
    # is equivalent to this:
    #
    #   key = TrustyCms::Config.find_or_create_by_key('key').value
    #
    # If the config item is boolean the response will be true or false. For items with :type => :integer it will be an integer,
    # for everything else a string.
    #
    def value
      if boolean?
        checked?
      else
        self[:value]
      end
    end

    # Returns the definition associated with this config item. If none has been declared this will be an empty definition
    # that does not restrict use.
    #
    def definition
      @definition ||= self.class.definition_for(key)
    end

    # Returns true if the item key ends with '?' or the definition specifies :type => :boolean.
    #
    def boolean?
      definition.boolean? || key.ends_with?('?')
    end

    # Returns true if the item is boolean and true.
    #
    def checked?
      return nil if self[:value].nil?

      boolean? && self[:value] == 'true'
    end

    # Returns true if the item defintion includes a :select_from parameter that limits the range of permissible options.
    #
    def selector?
      definition.selector?
    end

    # Returns a name corresponding to the current setting value, if the setting definition includes a select_from parameter.
    #
    def selected_value
      definition.selected(value)
    end

    def update_cache
      TrustyCms::Config.initialize_cache
    end

    delegate :default, :type, :allow_blank?, :hidden?, :visible?, :settable?, :selection, :notes, :units, to: :definition

    def validate
      definition.validate(self)
    end
  end
end
