require 'buff/extensions'
require 'json'

module VariaModel
  require_relative 'varia_model/version'
  require_relative 'varia_model/attributes'

  module ClassMethods
    ASSIGNMENT_MODES = [
      :whitelist,
      :carefree
    ]

    # @return [VariaModel::Attributes]
    def attributes
      @attributes ||= Attributes.new
    end

    # @return [Hashie::Mash]
    def validations
      @validations ||= Hashie::Mash.new
    end

    # @return [Symbol]
    def assignment_mode
      @assignment_mode ||= :whitelist
    end

    # Set the attribute mass assignment mode
    #   * :whitelist - only attributes defined on the class will have values set
    #   * :carefree  - values will be set for attributes that are not explicitly defined
    #                  in the class definition
    #
    # @param [Symbol] mode
    #   an assignment mode to use @see {ASSIGNMENT_MODES}
    def set_assignment_mode(mode)
      unless ASSIGNMENT_MODES.include?(mode)
        raise ArgumentError, "unknown assignment mode: #{mode}"
      end

      @assignment_mode = mode
    end

    # @param [#to_s] name
    # @option options [Symbol, Array<Symbol>] :type
    # @option options [Buff::Boolean] :required
    # @option options [Object] :default
    # @option options [Proc] :coerce
    def attribute(name, options = {})
      name = name.to_s
      options[:type] = Array(options[:type])
      options[:required] ||= false

      register_attribute(name, options)
      define_mimic_methods(name, options)
    end

    # @param [String] name
    #
    # @return [Array]
    def validations_for(name)
      self.validations[name] ||= Array.new
    end

    # @param [Constant, Array<Constant>] types
    # @param [VariaModel] model
    # @param [String] key
    #
    # @return [Array]
    def validate_kind_of(types, model, key)
      errors  = Array.new
      types   = types.uniq
      matches = false

      types.each do |type|
        if model.get_attribute(key).is_a?(type)
          matches = true
          break
        end
      end

      if matches
        [ :ok, "" ]
      else
        types_msg = types.collect { |type| "'#{type}'" }
        [ :error, "Expected attribute: '#{key}' to be a type of: #{types_msg.join(', ')}" ]
      end
    end

    # Validate that the attribute on the given model has a non-nil value assigned
    #
    # @param [VariaModel] model
    # @param [String] key
    #
    # @return [Array]
    def validate_required(model, key)
      if model.get_attribute(key).nil?
        [ :error, "A value is required for attribute: '#{key}'" ]
      else
        [ :ok, "" ]
      end
    end

    private

      def register_attribute(name, options = {})
        if options[:type] && options[:type].any?
          unless options[:required]
            options[:type] << NilClass
          end
          register_validation(name, lambda { |object, key| validate_kind_of(options[:type], object, key) })
        end

        if options[:required]
          register_validation(name, lambda { |object, key| validate_required(object, key) })
        end

        class_eval do
          new_attributes = Attributes.from_dotted_path(name, options[:default])
          self.attributes.merge!(new_attributes)

          if options[:coerce].is_a?(Proc)
            register_coercion(name, options[:coerce])
          end
        end
      end

      def register_validation(name, fun)
        self.validations[name] = (self.validations_for(name) << fun)
      end

      def register_coercion(name, fun)
        self.attributes.container(name).set_coercion(name.split('.').last, fun)
      end

      def define_mimic_methods(name, options = {})
        fun_name = name.split('.').first

        class_eval do
          define_method(fun_name) do
            _attributes_[fun_name]
          end

          define_method("#{fun_name}=") do |value|
            value = if options[:coerce].is_a?(Proc)
              options[:coerce].call(value)
            else
              value
            end

            _attributes_[fun_name] = value
          end
        end
      end
  end

  class << self
    def included(base)
      base.extend(ClassMethods)
    end
  end

  # @return [Hashie::Mash]
  def validate
    self.class.validations.each do |attr_path, validations|
      validations.each do |validation|
        status, messages = validation.call(self, attr_path)

        if status == :error
          if messages.is_a?(Array)
            messages.each do |message|
              self.add_error(attr_path, message)
            end
          else
            self.add_error(attr_path, messages)
          end
        end
      end
    end

    self.errors
  end

  # @return [Buff::Boolean]
  def valid?
    validate.empty?
  end

  # @return [Hashie::Mash]
  def errors
    @errors ||= Hashie::Mash.new
  end

  # Assigns the attributes of a model from a given hash of attributes.
  #
  # If the assignment mode is set to `:whitelist`, then only the values of keys which have a
  # corresponding attribute definition on the model will be set. All other keys will have their
  # values ignored.
  #
  # If the assignment mode is set to `:carefree`, then the attributes hash will be populated
  # with any key/values that are provided.
  #
  # @param [Hash] new_attrs
  def mass_assign(new_attrs = {})
    case self.class.assignment_mode
    when :whitelist
      whitelist_assign(new_attrs)
    when :carefree
      carefree_assign(new_attrs)
    end
  end

  # @param [#to_s] key
  #
  # @return [Object]
  def get_attribute(key)
    eval_as_proc(_attributes_.dig(key.to_s))
  end
  alias_method :[], :get_attribute

  # @param [#to_s] key
  # @param [Object] value
  def set_attribute(key, value)
    _attributes_.deep_merge!(Attributes.from_dotted_path(key.to_s, value))
  end
  alias_method :[]=, :set_attribute

  # @param [#to_hash] hash
  #
  # @return [self]
  def from_hash(hash)
    mass_assign(hash.to_hash)
    self
  end

  # @param [String] data
  #
  # @return [self]
  def from_json(data)
    mass_assign(JSON.parse(data))
    self
  end

  # The storage hash containing all of the key/values for this object's attributes
  #
  # @return [Hashie::Mash]
  def _attributes_
    @_attributes_ ||= self.class.attributes.dup
  end
  alias_method :to_hash, :_attributes_

  # @option options [Buff::Boolean] :symbolize_keys
  # @option options [Class, Symbol, String] :adapter
  #
  # @return [String]
  def to_json(*options)
    as_json.to_json(*options)
  end

  # @return [Hash]
  def as_json(*)
    opts = {}
    opts[JSON.create_id] = self.class.name if JSON.create_id
    to_hash.merge(opts)
  end

  # Convert the object to a hash.
  def to_hash
    _attributes_.inject({}) { |h, (k,v)| h[k] = eval_as_proc(v); h }
  end

  protected

    # @param [String] attribute
    # @param [String] message
    def add_error(attribute, message)
      self.errors[attribute] ||= Array.new
      self.errors[attribute] << message
    end

  private

    # Send #call to the given object if it responds to it. If it doesn't, just return the
    # object.
    #
    # @param [#call]
    def eval_as_proc(obj)
      obj.respond_to?(:call) ? obj.call : obj
    end

    def carefree_assign(new_attrs = {})
      _attributes_.deep_merge!(new_attrs)
    end

    def whitelist_assign(new_attrs = {})
      self.class.attributes.dotted_paths.each do |dotted_path|
        value = new_attrs.dig(dotted_path)
        next if value.nil?

        set_attribute(dotted_path, value)
      end
    end
end
