require "active_support/concern"
require "paper_trail/attribute_serializers/object_changes_attribute"

module PaperTrail
  # Originally, PaperTrail did not provide this module, and all of this
  # functionality was in `PaperTrail::Version`. That model still exists (and is
  # used by most apps) but by moving the functionality to this module, people
  # can include this concern instead of sub-classing the `Version` model.
  module VersionConcern
    extend ::ActiveSupport::Concern

    included do
      belongs_to :item, polymorphic: true

      # Since the test suite has test coverage for this, we want to declare
      # the association when the test suite is running. This makes it pass when
      # DB is not initialized prior to test runs such as when we run on Travis
      # CI (there won't be a db in `test/dummy/db/`).
      if PaperTrail.config.track_associations?
        has_many :version_associations, dependent: :destroy
      end

      validates_presence_of :event

      if PaperTrail.active_record_protected_attributes?
        attr_accessible(
          :item_type,
          :item_id,
          :event,
          :whodunnit,
          :object,
          :object_changes,
          :transaction_id,
          :created_at
        )
      end

      after_create :enforce_version_limit!

      scope :within_transaction, ->(id) { where transaction_id: id }
    end

    # :nodoc:
    module ClassMethods
      def with_item_keys(item_type, item_id)
        where item_type: item_type, item_id: item_id
      end

      def creates
        where event: "create"
      end

      def updates
        where event: "update"
      end

      def destroys
        where event: "destroy"
      end

      def not_creates
        where "event <> ?", "create"
      end

      # Returns versions after `obj`.
      #
      # @param obj - a `Version` or a timestamp
      # @param timestamp_arg - boolean - When true, `obj` is a timestamp.
      #   Default: false.
      # @return `ActiveRecord::Relation`
      # @api public
      def subsequent(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
        end

        obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
        where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(timestamp_sort_order)
      end

      # Returns versions before `obj`.
      #
      # @param obj - a `Version` or a timestamp
      # @param timestamp_arg - boolean - When true, `obj` is a timestamp.
      #   Default: false.
      # @return `ActiveRecord::Relation`
      # @api public
      def preceding(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
        end

        obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
        where(arel_table[PaperTrail.timestamp_field].lt(obj)).
          order(timestamp_sort_order("desc"))
      end

      def between(start_time, end_time)
        where(
          arel_table[PaperTrail.timestamp_field].gt(start_time).
          and(arel_table[PaperTrail.timestamp_field].lt(end_time))
        ).order(timestamp_sort_order)
      end

      # Defaults to using the primary key as the secondary sort order if
      # possible.
      def timestamp_sort_order(direction = "asc")
        [arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
          array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int?
        end
      end

      # Query the `versions.objects` column using the SQL LIKE operator.
      # Performs an attribute search on the serialized object by invoking the
      # identically-named method in the serializer being used.
      # @api public
      def where_object(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)

        if columns_hash["object"].type == :jsonb
          where("object @> ?", args.to_json)
        elsif columns_hash["object"].type == :json
          predicates = []
          values = []
          args.each do |field, value|
            predicates.push "object->>? = ?"
            values.concat([field, value.to_s])
          end
          sql = predicates.join(" and ")
          where(sql, *values)
        else
          arel_field = arel_table[:object]
          where_conditions = args.map { |field, value|
            PaperTrail.serializer.where_object_condition(arel_field, field, value)
          }
          where_conditions = where_conditions.reduce { |a, e| a.and(e) }
          where(where_conditions)
        end
      end

      # Query the `versions.object_changes` column by attributes, using the
      # SQL LIKE operator.
      # @api public
      def where_object_changes(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)

        if columns_hash["object_changes"].type == :jsonb
          args.each { |field, value| args[field] = [value] }
          where("object_changes @> ?", args.to_json)
        elsif columns_hash["object"].type == :json
          predicates = []
          values = []
          args.each do |field, value|
            predicates.push(
              "((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
            )
            values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
          end
          sql = predicates.join(" and ")
          where(sql, *values)
        else
          arel_field = arel_table[:object_changes]
          where_conditions = args.map { |field, value|
            PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
          }
          where_conditions = where_conditions.reduce { |a, e| a.and(e) }
          where(where_conditions)
        end
      end

      def primary_key_is_int?
        @primary_key_is_int ||= columns_hash[primary_key].type == :integer
      rescue
        true
      end

      # Returns whether the `object` column is using the `json` type supported
      # by PostgreSQL.
      def object_col_is_json?
        [:json, :jsonb].include?(columns_hash["object"].type)
      end

      # Returns whether the `object_changes` column is using the `json` type
      # supported by PostgreSQL.
      def object_changes_col_is_json?
        [:json, :jsonb].include?(columns_hash["object_changes"].try(:type))
      end
    end

    # @api private
    def object_deserialized
      if self.class.object_col_is_json?
        object
      else
        PaperTrail.serializer.load(object)
      end
    end

    # Restore the item from this version.
    #
    # Optionally this can also restore all :has_one and :has_many (including
    # has_many :through) associations as they were "at the time", if they are
    # also being versioned by PaperTrail.
    #
    # Options:
    #
    # - :has_one
    #   - `true` - Also reify has_one associations.
    #   - `false - Default.
    # - :has_many
    #   - `true` - Also reify has_many and has_many :through associations.
    #   - `false` - Default.
    # - :mark_for_destruction
    #   - `true` - Mark the has_one/has_many associations that did not exist in
    #     the reified version for destruction, instead of removing them.
    #   - `false` - Default. Useful for persisting the reified version.
    # - :dup
    #   - `false` - Default.
    #   - `true` - Always create a new object instance. Useful for
    #     comparing two versions of the same object.
    # - :unversioned_attributes
    #   - `:nil` - Default. Attributes undefined in version record are set to
    #     nil in reified record.
    #   - `:preserve` - Attributes undefined in version record are not modified.
    #
    def reify(options = {})
      return nil if object.nil?
      without_identity_map do
        ::PaperTrail::Reifier.reify(self, options)
      end
    end

    # Returns what changed in this version of the item.
    # `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
    # not have an `object_changes` text column.
    def changeset
      return nil unless self.class.column_names.include? "object_changes"
      @changeset ||= load_changeset
    end

    # Returns who put the item into the state stored in this version.
    def paper_trail_originator
      @paper_trail_originator ||= previous.try(:whodunnit)
    end

    def originator
      ::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
      paper_trail_originator
    end

    # Returns who changed the item from the state it had in this version. This
    # is an alias for `whodunnit`.
    def terminator
      @terminator ||= whodunnit
    end
    alias version_author terminator

    def sibling_versions(reload = false)
      if reload || @sibling_versions.nil?
        @sibling_versions = self.class.with_item_keys(item_type, item_id)
      end
      @sibling_versions
    end

    def next
      @next ||= sibling_versions.subsequent(self).first
    end

    def previous
      @previous ||= sibling_versions.preceding(self).first
    end

    # Returns an integer representing the chronological position of the
    # version among its siblings (see `sibling_versions`). The "create" event,
    # for example, has an index of 0.
    # @api public
    def index
      @index ||= RecordHistory.new(sibling_versions, self.class).index(self)
    end

    private

    # @api private
    def load_changeset
      # First, deserialize the `object_changes` column.
      changes = HashWithIndifferentAccess.new(object_changes_deserialized)

      # The next step is, perhaps unfortunately, called "de-serialization",
      # and appears to be responsible for custom attribute serializers. For an
      # example of a custom attribute serializer, see
      # `Person::TimeZoneSerializer` in the test suite.
      #
      # Is `item.class` good enough? Does it handle `inheritance_column`
      # as well as `Reifier#version_reification_class`? We were using
      # `item_type.constantize`, but that is problematic when the STI parent
      # is not versioned. (See `Vehicle` and `Car` in the test suite).
      #
      # Note: `item` returns nil if `event` is "destroy".
      unless item.nil?
        AttributeSerializers::ObjectChangesAttribute.
          new(item.class).
          deserialize(changes)
      end

      # Finally, return a Hash mapping each attribute name to
      # a two-element array representing before and after.
      changes
    end

    # If the `object_changes` column is a Postgres JSON column, then
    # ActiveRecord will deserialize it for us. Otherwise, it's a string column
    # and we must deserialize it ourselves.
    # @api private
    def object_changes_deserialized
      if self.class.object_changes_col_is_json?
        object_changes
      else
        begin
          PaperTrail.serializer.load(object_changes)
        rescue # TODO: Rescue something specific
          {}
        end
      end
    end

    # In Rails 3.1+, calling reify on a previous version confuses the
    # IdentityMap, if enabled. This prevents insertion into the map.
    # @api private
    def without_identity_map(&block)
      if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
        ::ActiveRecord::IdentityMap.without(&block)
      else
        yield
      end
    end

    # Checks that a value has been set for the `version_limit` config
    # option, and if so enforces it.
    # @api private
    def enforce_version_limit!
      limit = PaperTrail.config.version_limit
      return unless limit.is_a? Numeric
      previous_versions = sibling_versions.not_creates
      return unless previous_versions.size > limit
      excess_versions = previous_versions - previous_versions.last(limit)
      excess_versions.map(&:destroy)
    end
  end
end