# frozen_string_literal: true
# rubocop:todo all

require "active_model/attribute_methods"
require "mongoid/attributes/dynamic"
require "mongoid/attributes/embedded"
require "mongoid/attributes/nested"
require "mongoid/attributes/processing"
require "mongoid/attributes/projector"
require "mongoid/attributes/readonly"

module Mongoid

  # This module contains the logic for handling the internal attributes hash,
  # and how to get and set values.
  module Attributes
    extend ActiveSupport::Concern
    include Nested
    include Processing
    include Readonly

    attr_reader :attributes
    alias :raw_attributes :attributes

    # Determine if an attribute is present.
    #
    # @example Is the attribute present?
    #   person.attribute_present?("title")
    #
    # @param [ String | Symbol ] name The name of the attribute.
    #
    # @return [ true | false ] True if present, false if not.
    def attribute_present?(name)
      attribute = read_raw_attribute(name)
      !attribute.blank? || attribute == false
    rescue Mongoid::Errors::AttributeNotLoaded
      false
    end

    # Get the attributes that have not been cast.
    #
    # @example Get the attributes before type cast.
    #   document.attributes_before_type_cast
    #
    # @return [ Hash ] The uncast attributes.
    def attributes_before_type_cast
      @attributes_before_type_cast ||= {}
    end

    # Does the document have the provided attribute?
    #
    # @example Does the document have the attribute?
    #   model.has_attribute?(:name)
    #
    # @param [ String | Symbol ] name The name of the attribute.
    #
    # @return [ true | false ] If the key is present in the attributes.
    def has_attribute?(name)
      attributes.key?(name.to_s)
    end

    # Does the document have the provided attribute before it was assigned
    # and type cast?
    #
    # @example Does the document have the attribute before it was assigned?
    #   model.has_attribute_before_type_cast?(:name)
    #
    # @param [ String | Symbol ] name The name of the attribute.
    #
    # @return [ true | false ] If the key is present in the
    #   attributes_before_type_cast.
    def has_attribute_before_type_cast?(name)
      attributes_before_type_cast.key?(name.to_s)
    end

    # Read a value from the document attributes. If the value does not exist
    # it will return nil.
    #
    # @example Read an attribute.
    #   person.read_attribute(:title)
    #
    # @example Read an attribute (alternate syntax.)
    #   person[:title]
    #
    # @param [ String | Symbol ] name The name of the attribute to get.
    #
    # @return [ Object ] The value of the attribute.
    def read_attribute(name)
      field = fields[name.to_s]
      raw = read_raw_attribute(name)
      process_raw_attribute(name.to_s, raw, field)
    end
    alias :[] :read_attribute


    # Process the raw attribute values just read from the documents attributes.
    #
    # @param [ String ] name The name of the attribute to get.
    # @param [ Object ] raw The raw attribute value.
    # @param [ Field | nil ] field The field to use for demongoization or nil.
    #
    # @return [ Object ] The value of the attribute.
    #
    # @api private
    def process_raw_attribute(name, raw, field)
      value = field ? field.demongoize(raw) : raw
      attribute_will_change!(name) if value.resizable?
      value
    end

    # Read a value from the attributes before type cast. If the value has not
    # yet been assigned then this will return the attribute's existing value
    # using read_raw_attribute.
    #
    # @example Read an attribute before type cast.
    #   person.read_attribute_before_type_cast(:price)
    #
    # @param [ String | Symbol ] name The name of the attribute to get.
    #
    # @return [ Object ] The value of the attribute before type cast, if
    #   available. Otherwise, the value of the attribute.
    def read_attribute_before_type_cast(name)
      attr = name.to_s
      if attributes_before_type_cast.key?(attr)
        attributes_before_type_cast[attr]
      else
        read_raw_attribute(attr)
      end
    end

    # Remove a value from the +Document+ attributes. If the value does not exist
    # it will fail gracefully.
    #
    # @example Remove the attribute.
    #   person.remove_attribute(:title)
    #
    # @param [ String | Symbol ] name The name of the attribute to remove.
    #
    # @raise [ Errors::ReadonlyAttribute ] If the field cannot be removed due
    #   to being flagged as readonly.
    def remove_attribute(name)
      validate_writable_field_name!(name.to_s)
      as_writable_attribute!(name) do |access|
        _assigning do
          attribute_will_change!(access)
          delayed_atomic_unsets[atomic_attribute_name(access)] = [] unless new_record?
          attributes.delete(access)
        end
      end
    end

    # Write a single attribute to the document attribute hash. This will
    # also fire the before and after update callbacks, and perform any
    # necessary typecasting.
    #
    # @example Write the attribute.
    #   person.write_attribute(:title, "Mr.")
    #
    # @example Write the attribute (alternate syntax.)
    #   person[:title] = "Mr."
    #
    # @param [ String | Symbol ] name The name of the attribute to update.
    # @param [ Object ] value The value to set for the attribute.
    def write_attribute(name, value)
      validate_writable_field_name!(name.to_s)

      field_name = database_field_name(name)

      if attribute_missing?(field_name)
        raise Mongoid::Errors::AttributeNotLoaded.new(self.class, field_name)
      end

      if attribute_writable?(field_name)
        _assigning do
          localized = fields[field_name].try(:localized?)
          attributes_before_type_cast[name.to_s] = value
          typed_value = typed_value_for(field_name, value)
          unless attribute_will_not_change?(field_name, typed_value) || attribute_changed?(field_name)
            attribute_will_change!(field_name)
          end
          if localized
            present = fields[field_name].try(:localize_present?)
            loc_key, loc_val = typed_value.first
            if present && loc_val.blank?
              attributes[field_name]&.delete(loc_key)
            else
              attributes[field_name] ||= {}
              attributes[field_name].merge!(typed_value)
            end
          else
            attributes[field_name] = typed_value
          end

          # when writing an attribute, also remove it from the unsets,
          # so that removing then writing doesn't result in a removal.
          delayed_atomic_unsets.delete(field_name)

          typed_value
        end
      else
        # TODO: MONGOID-5072
      end
    end
    alias :[]= :write_attribute

    # Allows you to set all the attributes for a particular mass-assignment security role
    # by passing in a hash of attributes with keys matching the attribute names
    # (which again matches the column names)  and the role name using the :as option.
    # To bypass mass-assignment security you can use the :without_protection => true option.
    #
    # @example Assign the attributes.
    #   person.assign_attributes(:title => "Mr.")
    #
    # @example Assign the attributes (with a role).
    #   person.assign_attributes({ :title => "Mr." }, :as => :admin)
    #
    # @param [ Hash ] attrs The new attributes to set.
    def assign_attributes(attrs = nil)
      _assigning do
        process_attributes(attrs)
      end
    end

    # Writes the supplied attributes hash to the document. This will only
    # overwrite existing attributes if they are present in the new +Hash+, all
    # others will be preserved.
    #
    # @example Write the attributes.
    #   person.write_attributes(:title => "Mr.")
    #
    # @example Write the attributes (alternate syntax.)
    #   person.attributes = { :title => "Mr." }
    #
    # @param [ Hash ] attrs The new attributes to set.
    def write_attributes(attrs = nil)
      assign_attributes(attrs)
    end
    alias :attributes= :write_attributes

    # Determine if the attribute is missing from the document, due to loading
    # it from the database with missing fields.
    #
    # @example Is the attribute missing?
    #   document.attribute_missing?("test")
    #
    # @param [ String ] name The name of the attribute.
    #
    # @return [ true | false ] If the attribute is missing.
    def attribute_missing?(name)
      !Projector.new(__selected_fields).attribute_or_path_allowed?(name)
    end

    # Return type-casted attributes.
    #
    # @example Type-casted attributes.
    #   document.typed_attributes
    #
    # @return [ Object ] The hash with keys and values of the type-casted attributes.
    def typed_attributes
      attribute_names.map { |name| [name, send(name)] }.to_h
    end

    private

    # Does the string contain dot syntax for accessing hashes?
    #
    # @api private
    #
    # @example Is the string in dot syntax.
    #   model.hash_dot_syntax?
    #
    # @return [ true | false ] If the string contains a "."
    def hash_dot_syntax?(string)
      string.include?(".")
    end

    # Return the typecasted value for a field.
    #
    # @example Get the value typecasted.
    #   person.typed_value_for(:title, :sir)
    #
    # @param [ String | Symbol ] key The field name.
    # @param [ Object ] value The uncast value.
    #
    # @return [ Object ] The cast value.
    def typed_value_for(key, value)
      fields.key?(key) ? fields[key].mongoize(value) : value.mongoize
    end

    private

    def read_raw_attribute(name)
      normalized = database_field_name(name.to_s)

      if attribute_missing?(normalized)
        raise Mongoid::Errors::AttributeNotLoaded.new(self.class, name)
      end

      if hash_dot_syntax?(normalized)
        if fields.key?(normalized)
          attributes[normalized]
        else
          Embedded.traverse(attributes, normalized)
        end
      else
        attributes[normalized]
      end
    end

    module ClassMethods

      # Alias the provided name to the original field. This will provide an
      # aliased getter, setter, existence check, and all dirty attribute
      # methods.
      #
      # @example Alias the attribute.
      #   class Product
      #     include Mongoid::Document
      #     field :price, :type => Float
      #     alias_attribute :cost, :price
      #   end
      #
      # @param [ Symbol ] name The new name.
      # @param [ Symbol ] original The original name.
      def alias_attribute(name, original)
        aliased_fields[name.to_s] = original.to_s

        alias_method name, original
        alias_method "#{name}=", "#{original}="
        alias_method "#{name}?", "#{original}?"
        alias_method "#{name}_change", "#{original}_change"
        alias_method "#{name}_changed?", "#{original}_changed?"
        alias_method "reset_#{name}!", "reset_#{original}!"
        alias_method "reset_#{name}_to_default!", "reset_#{original}_to_default!"
        alias_method "#{name}_was", "#{original}_was"
        alias_method "#{name}_will_change!", "#{original}_will_change!"
        alias_method "#{name}_before_type_cast", "#{original}_before_type_cast"
      end

      # Removes a field alias.
      #
      # @param [ Symbol ] name The aliased field name to remove.
      def unalias_attribute(name)
        unless aliased_fields.delete(name.to_s)
          raise AttributeError, "Field #{name} is not an aliased field"
        end

        remove_method name
        remove_method "#{name}="
        remove_method "#{name}?"
        remove_method "#{name}_change"
        remove_method "#{name}_changed?"
        remove_method "reset_#{name}!"
        remove_method "reset_#{name}_to_default!"
        remove_method "#{name}_was"
        remove_method "#{name}_will_change!"
        remove_method "#{name}_before_type_cast"
      end
    end

    private

    def lookup_attribute_presence(name, value)
      if localized_fields.has_key?(name) && value
        value = localized_fields[name].send(:lookup, value)
      end
      value.present?
    end

    # If `value` is a `BSON::Decimal128`, convert it to a `BigDecimal` for
    # comparison purposes. This is necessary because `BSON::Decimal128` does
    # not implement `#==` in a way that is compatible with `BigDecimal`.
    def normalize_value(value)
      value.is_a?(BSON::Decimal128) ? BigDecimal(value.to_s) : value
    end

    # Determine if the attribute will not change, by comparing the current
    # value with the new value. The values are normalized to account for
    # types that do not implement `#==` in a way that is compatible with
    # each other, such as `BSON::Decimal128` and `BigDecimal`.
    def attribute_will_not_change?(field_name, typed_value)
      normalized_attribute = normalize_value(attributes[field_name])
      normalized_typed_value = normalize_value(typed_value)

      normalized_attribute == normalized_typed_value
    end
  end
end
