# frozen_string_literal: true

require "paper_trail/attribute_serializers/object_changes_attribute"
require "paper_trail/queries/versions/where_attribute_changes"
require "paper_trail/queries/versions/where_object"
require "paper_trail/queries/versions/where_object_changes"
require "paper_trail/queries/versions/where_object_changes_from"
require "paper_trail/queries/versions/where_object_changes_to"

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

    E_YAML_PERMITTED_CLASSES = <<-EOS.squish.freeze
      PaperTrail encountered a Psych::DisallowedClass error during
      deserialization of YAML column, indicating that
      yaml_column_permitted_classes has not been configured correctly. %s
    EOS

    included do
      belongs_to :item, polymorphic: true, optional: true, inverse_of: false
      validates_presence_of :event
      after_create :enforce_version_limit!
    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.not(event: "create")
      end

      def between(start_time, end_time)
        where(
          arel_table[:created_at].gt(start_time).
          and(arel_table[:created_at].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[:created_at].send(direction.downcase)].tap do |array|
          array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int?
        end
      end

      # Given an attribute like `"name"`, query the `versions.object_changes`
      # column for any changes that modified the provided attribute.
      #
      # @api public
      def where_attribute_changes(attribute)
        unless attribute.is_a?(String) || attribute.is_a?(Symbol)
          raise ArgumentError, "expected to receive a String or Symbol"
        end

        Queries::Versions::WhereAttributeChanges.new(self, attribute).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects` column.
      #
      # ```
      # SELECT "versions".*
      # FROM "versions"
      # WHERE ("versions"."object" LIKE '%
      # name: Joan
      # %')
      # ```
      #
      # This is useful for finding versions where a given attribute had a given
      # value. Imagine, in the example above, that Joan had changed her name
      # and we wanted to find the versions before that change.
      #
      # Based on the data type of the `object` column, the appropriate SQL
      # operator is used. For example, a text column will use `like`, and a
      # jsonb column will use `@>`.
      #
      # @api public
      def where_object(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObject.new(self, args).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects_changes` column.
      #
      # ```
      # SELECT "versions".*
      # FROM "versions"
      # WHERE .. ("versions"."object_changes" LIKE '%
      # name:
      # - Joan
      # %' OR "versions"."object_changes" LIKE '%
      # name:
      # -%
      # - Joan
      # %')
      # ```
      #
      # This is useful for finding versions immediately before and after a given
      # attribute had a given value. Imagine, in the example above, that someone
      # changed their name to Joan and we wanted to find the versions
      # immediately before and after that change.
      #
      # Based on the data type of the `object` column, the appropriate SQL
      # operator is used. For example, a text column will use `like`, and a
      # jsonb column will use `@>`.
      #
      # @api public
      def where_object_changes(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObjectChanges.new(self, args).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects_changes` column for changes where the version changed
      # from the hash of attributes to other values.
      #
      # This is useful for finding versions where the attribute started with a
      # known value and changed to something else. This is in comparison to
      # `where_object_changes` which will find both the changes before and
      # after.
      #
      # @api public
      def where_object_changes_from(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObjectChangesFrom.new(self, args).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects_changes` column for changes where the version changed
      # to the hash of attributes from other values.
      #
      # This is useful for finding versions where the attribute started with an
      # unknown value and changed to a known value. This is in comparison to
      # `where_object_changes` which will find both the changes before and
      # after.
      #
      # @api public
      def where_object_changes_to(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObjectChangesTo.new(self, args).execute
      end

      def primary_key_is_int?
        @primary_key_is_int ||= columns_hash[primary_key].type == :integer
      rescue StandardError # TODO: Rescue something more specific
        true
      end

      # Returns whether the `object` column is using the `json` type supported
      # by PostgreSQL.
      def object_col_is_json?
        %i[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?
        %i[json jsonb].include?(columns_hash["object_changes"].try(:type))
      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
      # rubocop:disable Style/OptionalBooleanParameter
      def preceding(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          preceding_by_id(obj)
        else
          preceding_by_timestamp(obj)
        end
      end
      # rubocop:enable Style/OptionalBooleanParameter

      # 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
      # rubocop:disable Style/OptionalBooleanParameter
      def subsequent(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          subsequent_by_id(obj)
        else
          subsequent_by_timestamp(obj)
        end
      end
      # rubocop:enable Style/OptionalBooleanParameter

      private

      # @api private
      def preceding_by_id(obj)
        where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
      end

      # @api private
      def preceding_by_timestamp(obj)
        obj = obj.send(:created_at) if obj.is_a?(self)
        where(arel_table[:created_at].lt(obj)).
          order(timestamp_sort_order("desc"))
      end

      # @api private
      def subsequent_by_id(version)
        where(arel_table[primary_key].gt(version.id)).order(arel_table[primary_key].asc)
      end

      # @api private
      def subsequent_by_timestamp(obj)
        obj = obj.send(:created_at) if obj.is_a?(self)
        where(arel_table[:created_at].gt(obj)).order(timestamp_sort_order)
      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.
    #
    # Options:
    #
    # - :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 = {})
      unless self.class.column_names.include? "object"
        raise Error, "reify requires an object column"
      end
      return nil if object.nil?
      ::PaperTrail::Reifier.reify(self, options)
    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

    # 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 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. 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
      if PaperTrail.config.object_changes_adapter.respond_to?(:load_changeset)
        return PaperTrail.config.object_changes_adapter.load_changeset(self)
      end

      # First, deserialize the `object_changes` column.
      changes = ActiveSupport::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 StandardError => e
          if defined?(::Psych::Exception) && e.instance_of?(::Psych::Exception)
            ::Kernel.warn format(E_YAML_PERMITTED_CLASSES, e)
          end
          {}
        end
      end
    end

    # Enforces the `version_limit`, if set. Default: no limit.
    # @api private
    def enforce_version_limit!
      limit = version_limit
      return unless limit.is_a? Numeric
      previous_versions = sibling_versions.not_creates.
        order(self.class.timestamp_sort_order("asc"))
      return unless previous_versions.size > limit
      excess_versions = previous_versions - previous_versions.last(limit)
      excess_versions.map(&:destroy)
    end

    # @api private
    def sibling_versions
      @sibling_versions ||= self.class.with_item_keys(item_type, item_id)
    end

    # See docs section 2.e. Limiting the Number of Versions Created.
    # The version limit can be global or per-model.
    #
    # @api private
    def version_limit
      klass = item.class
      if limit_option?(klass)
        klass.paper_trail_options[:limit]
      elsif base_class_limit_option?(klass)
        klass.base_class.paper_trail_options[:limit]
      else
        PaperTrail.config.version_limit
      end
    end

    def limit_option?(klass)
      klass.respond_to?(:paper_trail_options) && klass.paper_trail_options.key?(:limit)
    end

    def base_class_limit_option?(klass)
      klass.respond_to?(:base_class) && limit_option?(klass.base_class)
    end
  end
end
