require 'draftsman/attributes_serialization'

module Draftsman
  module Model

    def self.included(base)
      base.send :extend, ClassMethods
    end

    module ClassMethods
      # Declare this in your model to enable the Draftsman API for it. A draft
      # of the model is available in the `draft` association (if one exists).
      #
      # Options:
      #
      # :class_name
      # The name of a custom `Draft` class. This class should inherit from
      # `Draftsman::Draft`. A global default can be set for this using
      # `Draftsman.draft_class_name=` if the default of `Draftsman::Draft` needs
      # to be overridden.
      #
      # :ignore
      # An array of attributes for which an update to a `Draft` will not be
      # stored if they are the only ones changed.
      #
      # :only
      # Inverse of `ignore` - a new `Draft` will be created only for these
      # attributes if supplied. It's recommended that you only specify optional
      # attributes for this (that can be empty).
      #
      # :skip
      # Fields to ignore completely.  As with `ignore`, updates to these fields
      # will not create a new `Draft`. In addition, these fields will not be
      # included in the serialized versions of the object whenever a new `Draft`
      # is created.
      #
      # :meta
      # A hash of extra data to store. You must add a column to the `drafts`
      # table for each key. Values are objects or `procs` (which are called with
      # `self`, i.e. the model with the `has_drafts`). See
      # `Draftsman::Controller.info_for_draftsman` for an example of how to
      # store data from the controller.
      #
      # :draft
      # The name to use for the `draft` association shortcut method. Default is
      # `:draft`.
      #
      # :published_at
      # The name to use for the method which returns the published timestamp.
      # Default is `published_at`.
      #
      # :trashed_at
      # The name to use for the method which returns the soft delete timestamp.
      # Default is `trashed_at`.
      def has_drafts(options = {})
        # Lazily include the instance methods so we don't clutter up
        # any more ActiveRecord models than we need to.
        send :include, InstanceMethods
        send :extend, AttributesSerialization

        # Define before/around/after callbacks on each drafted model
        send :extend, ActiveModel::Callbacks
        # TODO: Remove `draft_creation`, `draft_update`, and `draft_destroy` in
        # v1.0.
        define_model_callbacks :save_draft, :draft_creation, :draft_update, :draft_destruction, :draft_destroy

        class_attribute :draftsman_options
        self.draftsman_options = options.dup

        class_attribute :draft_association_name
        self.draft_association_name = options[:draft] || :draft

        class_attribute :draft_class_name
        self.draft_class_name = options[:class_name] || Draftsman.draft_class_name

        [:ignore, :skip, :only].each do |key|
          draftsman_options[key] = ([draftsman_options[key]].flatten.compact || []).map(&:to_s)
        end

        draftsman_options[:ignore] << "#{self.draft_association_name}_id"

        draftsman_options[:meta] ||= {}

        attr_accessor :draftsman_event

        class_attribute :published_at_attribute_name
        self.published_at_attribute_name = options[:published_at] || :published_at

        class_attribute :trashed_at_attribute_name
        self.trashed_at_attribute_name = options[:trashed_at] || :trashed_at

        # `belongs_to :draft` association
        if ::Draftsman.active_record_belongs_to_required?
          belongs_to(self.draft_association_name, class_name: self.draft_class_name, dependent: :destroy, optional: true)
        else
          belongs_to(self.draft_association_name, class_name: self.draft_class_name, dependent: :destroy)
        end

        # Scopes
        scope :drafted, -> (referenced_table_name = nil) {
          referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name
          where.not(referenced_table_name => { "#{self.draft_association_name}_id" => nil })
        }

        scope :published, -> (referenced_table_name = nil) {
          referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name
          where.not(referenced_table_name => { self.published_at_attribute_name => nil })
        }

        scope :trashed, -> (referenced_table_name = nil) {
          referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name
          where.not(referenced_table_name => { self.trashed_at_attribute_name => nil })
        }

        scope :live, -> (referenced_table_name = nil) {
          referenced_table_name = referenced_table_name.present? ? referenced_table_name : table_name
          where(referenced_table_name => { self.trashed_at_attribute_name => nil })
        }
      end

      # Returns draft class.
      def draft_class
        @draft_class ||= draft_class_name.constantize
      end

      # Returns whether or not `has_drafts` has been called on this model.
      def draftable?
        method_defined?(:draftsman_options)
      end

      # Returns whether or not a `trashed_at` timestamp is set up on this model.
      def trashable?
        draftable? && method_defined?(self.trashed_at_attribute_name)
      end
    end

    module InstanceMethods
      # Returns the latest revision -- either the object or its reified draft
      def latest_revision
        draft? ? draft.reify : self
      end

      # Returns whether or not this item has a draft.
      def draft?
        send(self.class.draft_association_name).present?
      end

      # DEPRECATED: Use `#draft_save` instead.
      def draft_creation
        ActiveSupport::Deprecation.warn('`#draft_creation` is deprecated and will be removed from Draftsman 1.0. Use `#save_draft` instead.')
        _draft_creation
      end

      # DEPRECATED: Use `#draft_destruction` instead.
      def draft_destroy
        ActiveSupport::Deprecation.warn('`#draft_destroy` is deprecated and will be removed from Draftsman 1.0. Use `draft_destruction` instead.')

        run_callbacks :draft_destroy do
          _draft_destruction
        end
      end

      # Trashes object and records a draft for a `destroy` event.
      def draft_destruction
        run_callbacks :draft_destruction do
          _draft_destruction
        end
      end

      # DEPRECATED: Use `#draft_save` instead.
      def draft_update
        ActiveSupport::Deprecation.warn('`#draft_update` is deprecated and will be removed from Draftsman 1.0. Use `#save_draft` instead.')
        _draft_update
      end

      # Returns serialized object representing this drafted item.
      def object_attrs_for_draft_record(object = nil)
        object ||= self

        attrs = object.attributes.except(*self.class.draftsman_options[:skip]).tap do |attributes|
          self.class.serialize_attributes_for_draftsman(attributes)
        end

        if self.class.draft_class.object_col_is_json?
          attrs
        else
          Draftsman.serializer.dump(attrs)
        end
      end

      # Returns whether or not this item has been published at any point in its lifecycle.
      def published?
        self.published_at.present?
      end

      # Creates or updates draft depending on state of this item and if it has
      # any drafts.
      #
      # -  If a completely new record, persists this item to the database and
      #    records a `create` draft.
      # -  If an existing record with an existing `create` draft, updates the
      #    record and the existing `create` draft.
      # -  If an existing record with no existing draft, records changes in an
      #    `update` draft.
      # -  If an existing record with an existing draft (`create` or `update`),
      #    updated back to its original undrafted state, removes associated
      #    `draft record`.
      #
      # Returns `true` or `false` depending on if the object passed validation
      # and the save was successful.
      def save_draft
        run_callbacks :save_draft do
          if self.new_record?
            _draft_creation
          else
            _draft_update
          end
        end
      end

      # Returns whether or not this item has been trashed
      def trashed?
        send(self.class.trashed_at_attribute_name).present?
      end

    private

      # Creates object and records a draft for the object's creation. Returns
      # `true` or `false` depending on whether or not the objects passed
      # validation and the save was successful.
      def _draft_creation
        transaction do
          # TODO: Remove callback wrapper in v1.0.
          run_callbacks :draft_creation do
            # We want to save the draft after create
            return false unless self.save

            # Build data to store in draft record.
            data = {
              item: self,
              event: :create,
            }
            data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes?
            data[Draftsman.whodunnit_field] = Draftsman.whodunnit
            data[:object_changes] = serialized_draft_changeset(changes_for_draftsman(:create)) if track_object_changes_for_draft?
            data = merge_metadata_for_draft(data)
            send("build_#{self.class.draft_association_name}", data)

            if send(self.class.draft_association_name).save
              fk = "#{self.class.draft_association_name}_id"
              id = send(self.class.draft_association_name).id
              self.update_column(fk, id)
            else
              raise ActiveRecord::Rollback and return false
            end
          end
        end

        return true
      end

      # This is only abstracted away at this moment because of the
      # `draft_destroy` deprecation. Move all of this logic back into
      # `draft_destruction` after `draft_destroy is removed.`
      def _draft_destruction
        transaction do
          data = {
            item: self,
            event: :destroy
          }
          data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes?
          data[Draftsman.whodunnit_field] = Draftsman.whodunnit

          # Stash previous draft in case it needs to be reverted later
          if self.draft?
            attrs = send(self.class.draft_association_name).attributes

            data[:previous_draft] =
              if self.class.draft_class.previous_draft_col_is_json?
                attrs
              else
                Draftsman.serializer.dump(attrs)
              end
          end

          data = merge_metadata_for_draft(data)

          if send(self.class.draft_association_name).present?
            send(self.class.draft_association_name).update!(data)
          else
            send("build_#{self.class.draft_association_name}", data)
            send(self.class.draft_association_name).save!
            send("#{self.class.draft_association_name}_id=", send(self.class.draft_association_name).id)
            self.update_column("#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id)
          end

          trash!

          # Mock `dependent: :destroy` behavior for all trashable associations
          dependent_associations = self.class.reflect_on_all_associations(:has_one) + self.class.reflect_on_all_associations(:has_many)

          dependent_associations.each do |association|
            if association.klass.draftable? && association.options.has_key?(:dependent) && association.options[:dependent] == :destroy
              dependents = self.send(association.name)
              dependents = [dependents] if (dependents && association.macro == :has_one)

              if dependents
                dependents.each do |dependent|
                  dependent.draft_destruction unless dependent.draft? && dependent.send(dependent.class.draft_association_name).destroy?
                end
              end
            end
          end
        end
      end

      # Updates object and records a draft for an `update` event. If the draft
      # is being updated to the object's original state, the draft is destroyed.
      # Returns `true` or `false` depending on if the object passed validation
      # and the save was successful.
      def _draft_update
        # TODO: Remove callback wrapper in v1.0.
        transaction do
          run_callbacks :draft_update do
            # Run validations.
            return false unless self.valid?

            # If updating a create draft, also update this item.
            if self.draft? && send(self.class.draft_association_name).create?
              the_changes = changes_for_draftsman(:create)
              data = { item: self }
              data[Draftsman.whodunnit_field] = Draftsman.whodunnit
              data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes?
              data[:object_changes] = serialized_draft_changeset(the_changes) if track_object_changes_for_draft?

              data = merge_metadata_for_draft(data)
              send(self.class.draft_association_name).update(data)
              save
            else
              the_changes = changes_for_draftsman(:update)
              save_only_columns_for_draft if Draftsman.stash_drafted_changes?

              # Destroy the draft if this record has changed back to the
              # original values.
              if self.draft? && the_changes.empty?
                nilified_draft = send(self.class.draft_association_name)
                touch = changed?
                send("#{self.class.draft_association_name}_id=", nil)
                save(touch: touch)
                nilified_draft.destroy
              # Save an update draft if record is changed notably.
              elsif !the_changes.empty?
                data = { item: self, event: :update }
                data[Draftsman.whodunnit_field] = Draftsman.whodunnit
                data[:object] = object_attrs_for_draft_record if Draftsman.stash_drafted_changes?
                data[:object_changes] = serialized_draft_changeset(the_changes) if track_object_changes_for_draft?
                data = merge_metadata_for_draft(data)

                # If there's already a draft, update it.
                if self.draft?
                  send(self.class.draft_association_name).update(data)

                  if Draftsman.stash_drafted_changes?
                    update_skipped_attributes
                  else
                    self.save
                  end
                # If there's not an existing draft, create an update draft.
                else
                  send("build_#{self.class.draft_association_name}", data)

                  if send(self.class.draft_association_name).save
                    update_column("#{self.class.draft_association_name}_id", send(self.class.draft_association_name).id)

                    if Draftsman.stash_drafted_changes?
                      update_skipped_attributes
                    else
                      self.save
                    end
                  else
                    raise ActiveRecord::Rollback and return false
                  end
                end
              # Otherwise, just save the record.
              else
                self.save
              end
            end
          end
        end
      rescue Exception => e
        false
      end

      # Returns hash of attributes that have changed for the object, similar to
      # how ActiveRecord's `changes` works.
      def changes_for_draftsman(event)
        the_changes = {}
        ignore = self.class.draftsman_options[:ignore]
        skip   = self.class.draftsman_options[:skip]
        only   = self.class.draftsman_options[:only]
        draftable_attrs = self.attributes.keys - ignore - skip
        draftable_attrs = draftable_attrs & only if only.present?

        # If there's already an update draft, get its changes and reconcile them
        # manually.
        if event == :update
          # Collect all attributes' previous and new values.
          draftable_attrs.each do |attr|
            if self.draft? && self.draft.changeset && self.draft.changeset.key?(attr)
              the_changes[attr] = [self.draft.changeset[attr].first, send(attr)]
            else
              the_changes[attr] = [self.send("#{attr}_was"), send(attr)]
            end
          end
        # If there is no draft or it's for a create, then all draftable
        # attributes are the changes.
        else
          draftable_attrs.each { |attr| the_changes[attr] = [nil, send(attr)] }
        end

        # Purge attributes that haven't changed.
        the_changes.delete_if { |key, value| value.first == value.last }
      end

      # Merges model-level metadata from `meta` and `controller_info` into draft object.
      def merge_metadata_for_draft(data)
        # First, we merge the model-level metadata in `meta`.
        draftsman_options[:meta].each do |attribute, value|
          data[attribute] =
            if value.respond_to?(:call)
              value.call(self)
            elsif value.is_a?(Symbol) && respond_to?(value)
              # if it is an attribute that is changing, be sure to grab the current version
              if has_attribute?(value) && send("#{value}_changed?".to_sym)
                send("#{value}_was".to_sym)
              else
                send(value)
              end
            else
              value
            end
        end

        # Second, we merge any extra data from the controller (if available).
        data.merge(Draftsman.controller_info || {})
      end

      # Save columns outside of the `only` option directly to master table
      def save_only_columns_for_draft
        if self.class.draftsman_options[:only].any?
          only_changes = {}
          only_changed_attributes = self.attributes.keys - self.class.draftsman_options[:only]

          only_changed_attributes.each do |key|
            only_changes[key] = send(key) if changed.include?(key)
          end

          self.update_columns(only_changes) if only_changes.any?
        end
      end

      # Returns changeset data in format appropriate for `object_changes`
      # column.
      def serialized_draft_changeset(my_changes)
        self.class.draft_class.object_changes_col_is_json? ? my_changes : Draftsman.serializer.dump(my_changes)
      end

      # Returns whether or not the draft class includes an `object_changes` attribute.
      def track_object_changes_for_draft?
        self.class.draft_class.column_names.include?('object_changes')
      end

      # Sets `trashed_at` attribute to now and saves to the database immediately.
      def trash!
        self.update_column(self.class.trashed_at_attribute_name, Time.now)
      end

      # Updates skipped attributes' values on this model.
      def update_skipped_attributes
        # Skip over this if nothing's being skipped.
        skipped_changed = changed_attributes.keys & draftsman_options[:skip]
        return true unless skipped_changed.present?

        keys = self.attributes.keys.select { |key| draftsman_options[:skip].include?(key) }
        attrs = {}
        keys.each { |key| attrs[key] = self.send(key) }

        self.reload
        self.update(attrs)
      end
    end
  end
end
