# frozen_string_literal: true

module Dynamoid
  # This is the base module for all domain objects that need to be persisted to
  # the database as documents.
  module Document
    extend ActiveSupport::Concern
    include Dynamoid::Components

    included do
      class_attribute :options, :read_only_attributes, :base_class, instance_accessor: false
      self.options = {}
      self.read_only_attributes = []
      self.base_class = self

      Dynamoid.included_models << self unless Dynamoid.included_models.include? self
    end

    module ClassMethods
      def attr_readonly(*read_only_attributes)
        self.read_only_attributes.concat read_only_attributes.map(&:to_s)
      end

      # Returns the read capacity for this table.
      #
      # @return [Integer] read capacity units
      # @since 0.4.0
      def read_capacity
        options[:read_capacity] || Dynamoid::Config.read_capacity
      end

      # Returns the write_capacity for this table.
      #
      # @return [Integer] write capacity units
      # @since 0.4.0
      def write_capacity
        options[:write_capacity] || Dynamoid::Config.write_capacity
      end

      # Returns the billing (capacity) mode for this table.
      #
      # Could be either +provisioned+ or +on_demand+.
      #
      # @return [Symbol]
      def capacity_mode
        options[:capacity_mode] || Dynamoid::Config.capacity_mode
      end

      # Returns the field name used to support STI for this table.
      #
      # Default field name is +type+ but it can be overrided in the +table+
      # method call.
      #
      #   User.inheritance_field # => :type
      def inheritance_field
        options[:inheritance_field] || :type
      end

      # Returns the hash key field name for this class.
      #
      # By default +id+ field is used. But it can be overriden in the +table+
      # method call.
      #
      #   User.hash_key # => :id
      #
      # @return [Symbol] a hash key name
      # @since 0.4.0
      def hash_key
        options[:key] || :id
      end

      # Return the count of items for this class.
      #
      # It returns approximate value based on DynamoDB statistic. DynamoDB
      # updates it periodically so the value can be no accurate.
      #
      # It's a reletively cheap operation and doesn't read all the items in a
      # table. It makes just one HTTP request to DynamoDB.
      #
      # @return [Integer] items count in a table
      # @since 0.6.1
      def count
        Dynamoid.adapter.count(table_name)
      end

      # Initialize a new object.
      #
      #   User.build(name: 'A')
      #
      # Initialize an object and pass it into a block to set other attributes.
      #
      #   User.build(name: 'A') do |u|
      #     u.age = 21
      #   end
      #
      # The only difference between +build+ and +new+ methods is that +build+
      # supports STI (Single table inheritance) and looks at the inheritance
      # field. So it can build a model of actual class. For instance:
      #
      #   class Employee
      #     include Dynamoid::Document
      #
      #     field :type
      #     field :name
      #   end
      #
      #   class Manager < Employee
      #   end
      #
      #   Employee.build(name: 'Alice', type: 'Manager') # => #<Manager:0x00007f945756e3f0 ...>
      #
      # @param attrs [Hash] Attributes with which to create the document
      # @param block [Proc] Block to process a document after initialization
      # @return [Dynamoid::Document] the new document
      # @since 0.2.0
      def build(attrs = {}, &block)
        choose_right_class(attrs).new(attrs, &block)
      end

      # Does this model exist in a table?
      #
      #   User.exists?('713') # => true
      #
      # If a range key is declared it should be specified in the following way:
      #
      #   User.exists?([['713', 'range-key-value']]) # => true
      #
      # It's possible to check existence of several models at once:
      #
      #   User.exists?(['713', '714', '715'])
      #
      # Or in case when a range key is declared:
      #
      #   User.exists?(
      #     [
      #       ['713', 'range-key-value-1'],
      #       ['714', 'range-key-value-2'],
      #       ['715', 'range-key-value-3']
      #     ]
      #   )
      #
      # It's also possible to specify models not with primary key but with
      # conditions on the attributes (in the +where+ method style):
      #
      #   User.exists?(age: 20, 'created_at.gt': Time.now - 1.day)
      #
      # @param id_or_conditions [String|Array[String]|Array[Array]|Hash] the primary id of the model, a list of primary ids or a hash with the options to filter from.
      # @return [true|false]
      # @since 0.2.0
      def exists?(id_or_conditions = {})
        case id_or_conditions
        when Hash then where(id_or_conditions).count >= 1
        else
          begin
            find(id_or_conditions)
            true
          rescue Dynamoid::Errors::RecordNotFound
            false
          end
        end
      end

      attr_accessor :abstract_class

      def abstract_class?
        defined?(@abstract_class) && @abstract_class == true
      end

      def sti_name
        name
      end

      def sti_class_for(type_name)
        type_name.constantize
      rescue NameError
        raise Errors::SubclassNotFound, "STI subclass does not found. Subclass: '#{type_name}'"
      end

      # @private
      def deep_subclasses
        subclasses + subclasses.map(&:deep_subclasses).flatten
      end

      # @private
      def choose_right_class(attrs)
        attrs[inheritance_field] ? sti_class_for(attrs[inheritance_field]) : self
      end
    end

    # Initialize a new object.
    #
    #   User.new(name: 'A')
    #
    # Initialize an object and pass it into a block to set other attributes.
    #
    #   User.new(name: 'A') do |u|
    #     u.age = 21
    #   end
    #
    # @param attrs [Hash] Attributes with which to create the document
    # @param block [Proc] Block to process a document after initialization
    # @return [Dynamoid::Document] the new document
    #
    # @since 0.2.0
    def initialize(attrs = {}, &block)
      run_callbacks :initialize do
        @new_record = true
        @attributes ||= {}
        @associations ||= {}
        @attributes_before_type_cast ||= {}

        attrs_with_defaults = self.class.attributes.each_with_object({}) do |(attribute, options), res|
          if attrs.key?(attribute)
            res[attribute] = attrs[attribute]
          elsif options.key?(:default)
            res[attribute] = evaluate_default_value(options[:default])
          end
        end

        attrs_virtual = attrs.slice(*(attrs.keys - self.class.attributes.keys))

        load(attrs_with_defaults.merge(attrs_virtual))

        if block
          yield(self)
        end
      end
    end

    # Check equality of two models.
    #
    # A model is equal to another model only if their primary keys (hash key
    # and optionally range key) are equal.
    #
    # @return [true|false]
    # @since 0.2.0
    def ==(other)
      if self.class.identity_map_on?
        super
      else
        return false if other.nil?

        other.is_a?(Dynamoid::Document) && hash_key == other.hash_key && range_value == other.range_value
      end
    end

    # Check equality of two models.
    #
    # Works exactly like +==+ does.
    #
    # @return [true|false]
    def eql?(other)
      self == other
    end

    # Generate an Integer hash value for this model.
    #
    # Hash value is based on primary key. So models can be used safely as a
    # +Hash+ keys.
    #
    # @return [Integer]
    def hash
      [hash_key, range_value].hash
    end

    # Return a model's hash key value.
    #
    # @since 0.4.0
    def hash_key
      self[self.class.hash_key.to_sym]
    end

    # Assign a model's hash key value, regardless of what it might be called to
    # the object.
    #
    # @since 0.4.0
    def hash_key=(value)
      self[self.class.hash_key.to_sym] = value
    end

    # Return a model's range key value.
    #
    # Returns +nil+ if a range key isn't declared for a model.
    def range_value
      if self.class.range_key
        self[self.class.range_key.to_sym]
      end
    end

    # Assign a model's range key value.
    def range_value=(value)
      if self.class.range_key
        self[self.class.range_key.to_sym] = value
      end
    end

    def inspect
      # attributes order is:
      # - partition key
      # - sort key
      # - user defined attributes
      # - timestamps - created_at/updated_at
      names = [self.class.hash_key]
      names << self.class.range_key if self.class.range_key
      names += self.class.attributes.keys - names - %i[created_at updated_at]
      names << :created_at if self.class.attributes.key?(:created_at)
      names << :updated_at if self.class.attributes.key?(:updated_at)

      inspection = names.map do |name|
        value = read_attribute(name)
        "#{name}: #{value.inspect}"
      end.join(', ')

      "#<#{self.class.name} #{inspection}>"
    end

    private

    def dumped_range_value
      Dumping.dump_field(range_value, self.class.attributes[self.class.range_key])
    end

    # Evaluates the default value given, this is used by undump
    # when determining the value of the default given for a field options.
    #
    # @param val [Object] the attribute's default value
    def evaluate_default_value(val)
      if val.respond_to?(:call)
        val.call
      elsif val.duplicable?
        val.dup
      else
        val
      end
    end
  end
end

ActiveSupport.run_load_hooks(:dynamoid, Dynamoid::Document)
