module VirtualBox
  class AbstractModel
    # Tracks "dirtiness" of values for a class. Its not tied to AbstractModel
    # in any way other than the namespace.
    #
    # # Checking if a Value was Changed
    #
    # Dynamic methods allow functionality for checking if values changed:
    #
    #     obj.foo_changed?
    #
    # # Previous Value
    #
    # Can also view the previous value of an attribute:
    #
    #     obj.foo # => "foo" initially
    #     obj.foo = "bar"
    #     obj.foo_was # => "foo"
    #
    # # Previous and Current Value
    #
    # Using the `_change` dynamic method, can view the changes of a field.
    #
    #     obj.foo # => "foo" initially
    #     obj.foo = "bar"
    #     obj.foo_change # => ["foo", "bar"]
    #
    # # All Changes
    #
    # Can also view all changes for a class with the `changes` method.
    #
    #     obj.foo # => "foo" initially
    #     obj.bar # => "bar" initially
    #     obj.foo = "far"
    #     obj.bar = "baz"
    #     obj.changes # => { :foo => ["foo", "far"], :bar => ["bar", "baz"]}
    #
    # # Setting Dirty
    #
    # Dirtiness tracking only occurs for values which the implementor
    # explicitly sets as dirty. This is done with the {#set_dirty!}
    # method. Example implementation below:
    #
    #     class Person
    #       include VirtualBox::AbstractModel::Dirty
    #
    #       attr_reader :name
    #
    #       def name=(value)
    #         set_dirty!(:name, @name, value)
    #         @name = value
    #       end
    #     end
    #
    # The above example has all the changes necessary to track changes
    # on an attribute.
    #
    # # Ignoring Dirtiness Tracking
    #
    # Sometimes, for features such as mass assignment, dirtiness tracking
    # should be disabled. This can be done with the `ignore_dirty` method.
    #
    #     ignore_dirty do |obj|
    #       obj.name = "Foo"
    #     end
    #
    #     obj.changed? # => false
    #
    # # Clearing Dirty State
    #
    # Sometimes, such as after saving a model, dirty states should be cleared.
    # This can be done with the `clear_dirty!` method.
    #
    #     obj.clear_dirty!(:name)
    #     obj.name_changed? # => false
    #
    # If no specific field is speciied, `clear_dirty!` will clear the dirty
    # status on the entire model.
    #
    #     obj.changed? # => assume true
    #     obj.clear_dirty!
    #     obj.changed? # => false
    #
    module Dirty
      # Manages dirty state for an attribute. This method will handle
      # setting the dirty state of an attribute (or even clearing it
      # if the old value is reset). Any implementors of this mixin should
      # call this for any fields they want tracked.
      #
      # @param [Symbol] name Name of field
      # @param [Object] current Current value (not necessarilly the
      #   original value, but the **current** value)
      # @param [Object] value The new value being set
      def set_dirty!(name, current, value)
        if current != value
          name = name.to_sym

          # If its the first time this attribute has changed, store the
          # original value in the first field
          changes[name] ||= [current, nil]

          # Then store the changed value
          changes[name][1] = value

          # If the value changed back to the original value, remove from the
          # dirty hash
          if changes[name][0] == changes[name][1]
            changes.delete(name)
          end
        end
      end

      # Clears dirty state for a field.
      #
      # @param [Symbol] key The field to clear dirty state.
      def clear_dirty!(key=nil)
        if key.nil?
          @changed_attributes = {}
        else
          changes.delete(key)
        end
      end

      # Ignores any dirty changes during the duration of the block.
      # Guarantees the dirty state will be the same before and after
      # the method call, but not within the block itself.
      def ignore_dirty(&block)
        current_changes = changes.dup
        yield self
        @changed_attributes = current_changes
      end

      # Returns boolean denoting if field changed or not. If no attribute
      # is specified, returns true of false showing whether the model
      # changed at all.
      #
      # @param [Symbol] attribute The attribute to check, or if nil,
      #   all fields checked.
      def changed?(attribute = nil)
        if attribute.nil?
          !changes.empty?
        else
          changes.has_key?(attribute)
        end
      end

      # Returns hash of changes. Keys are fields, values are an
      # array of the original value and the current value.
      #
      # @return [Hash]
      def changes
        @changed_attributes ||= {}
      end

      # Method missing is used to implement the "magic" methods of
      # `field_changed`, `field_change`, and `field_was`.
      def method_missing(meth, *args)
        meth_string = meth.to_s

        if meth_string =~ /^(.+?)_changed\?$/
          changed?($1.to_sym)
        elsif meth_string =~ /^(.+?)_change$/
          changes[$1.to_sym]
        elsif meth_string =~ /^(.+?)_was$/
          change = changes[$1.to_sym]
          if change.nil?
            nil
          else
            change[0]
          end
        else
          super
        end
      end
    end
  end
end
