require 'contracts'
require 'contract_value_object/definition_error'
require 'contract_value_object/error_formatter'
require 'contract_value_object/awesome_print'

class ContractValueObject
  include Contracts

  class << self
    Contract Maybe[HashOf[Symbol, Any]] => HashOf[Symbol, Any]
    def attributes(attributes = nil)
      if attributes.nil?
        return @attributes if instance_variable_defined?(:@attributes)
        raise ArgumentError, 'Calling for attributes without having defined them.'
      end

      attr_accessor(*attributes.keys)
      attr_writers = attributes.keys.map { |attribute| :"#{attribute}=" }
      private *attr_writers

      @attributes = attributes
    end

    Contract Maybe[HashOf[Symbol, Any]] => HashOf[Symbol, Any]
    def defaults(defaults = nil)
      return @defaults ||= {} if defaults.nil?

      unexpected_defaults = defaults.keys - attributes.keys
      unless unexpected_defaults.empty?
        raise ArgumentError, "Unexpected defaults are set: #{unexpected_defaults.map(&:to_s).join(', ')}"
      end

      non_optional = defaults.select do |attribute, _|
        !attributes.fetch(attribute).is_a?(Contracts::Optional)
      end
      raise ArgumentError, "Unexpected non-optional defaults: #{non_optional.keys.join(', ')}" unless non_optional.empty?

      @defaults = defaults
    end

    attr_writer :error_presenter

    Contract RespondTo[:contract_failure, :missing, :unexpected]
    def error_presenter
      @error_presenter ||= self::ErrorFormatter.new
    end
  end

  Contract HashOf[Symbol, Any] => Any
  def initialize(**attributes)
    error_presenter = self.class.error_presenter
    class_attributes = self.class.attributes
    defaults = self.class.defaults
    error_message_class = DefinitionError::ErrorMessage

    # determine attributes that were not passed in but should have been
    missing_attributes = class_attributes.keys - attributes.keys - defaults.keys
    missing_attribute_errors = missing_attributes.flat_map do |attribute|
      next([]) if class_attributes[attribute].is_a?(Contracts::Optional)
      error_message_class.new(attribute, error_presenter.missing(attribute, class_attributes.fetch(attribute)))
    end

    # determine extraneous attributes that should not have been passed in
    unexpected_attributes = attributes.keys - class_attributes.keys
    unexpected_attribute_errors = unexpected_attributes.map do |attribute|
      error_message_class.new(attribute, error_presenter.unexpected(attribute))
    end

    # set attributes on the object and raise if they do not obey the contract
    setter_errors = defaults.merge(attributes).flat_map do |attribute, value|
      next([]) if unexpected_attributes.include?(attribute)

      begin
        contract = class_attributes.fetch(attribute)
        contract.within_opt_hash! if contract.is_a?(Contracts::Optional)
        # begin support customized setter
        send("#{attribute}=", value)
        set_value = instance_variable_get("@#{attribute}")
        # end support for customized setter
        if Contract.valid?(set_value, contract)
          []
        else
          raise ArgumentError, error_presenter.contract_failure(attribute, set_value, contract)
        end
      rescue self.class::DefinitionError => error
        error_message_class.new(attribute, error.message.gsub("\n", "\n\t"))
      rescue StandardError => error
        error_message_class.new(attribute, error.message)
      end
    end

    errors = missing_attribute_errors + unexpected_attribute_errors + setter_errors
    return if errors.empty?

    raise DefinitionError, errors
  end

  def to_h
    key_value_pairs = self.class.attributes.map do |attribute, _type|
      [attribute, public_send(attribute)]
    end

    key_value_pairs.to_h
  end

  # treating contract value objects with the same values as the same object for hashes and sets
  def hash
    to_h.hash
  end

  def ==(other)
    return super(other) unless self.class == other.class

    self.class.attributes.all? do |attribute, _type|
      public_send(attribute) == other.public_send(attribute)
    end
  end

  alias_method :eql?, :==

  def inspect
    "#{self.class} #{to_h.inspect}"
  end
end
