# Save the given Resource#model, and all of its nested relationships.
# @api private
class Graphiti::Util::Persistence
  # @param [Resource] resource the resource instance
  # @param [Hash] meta see (Deserializer#meta)
  # @param [Hash] attributes see (Deserializer#attributes)
  # @param [Hash] relationships see (Deserializer#relationships)
  # @param [Model] caller_model The persisted parent object in the request graph
  # @param [Symbol] foreign_key Attribute assigned by parent object in graph
  def initialize(resource, meta, attributes, relationships, caller_model, foreign_key = nil)
    @resource = resource
    @meta = meta
    @attributes = attributes
    @relationships = relationships
    @caller_model = caller_model
    @foreign_key = foreign_key
    @adapter = @resource.adapter

    # Find the correct child resource for a given jsonapi type
    if (meta_type = @meta[:type].try(:to_sym))
      if @resource.type != meta_type && @resource.polymorphic?
        @resource = @resource.class.resource_for_type(meta_type).new
      end
    end
  end

  # Perform the actual save logic.
  #
  # belongs_to must be processed before/separately from has_many -
  # we need to know the primary key value of the parent before
  # persisting the child.
  #
  # Flow:
  # * process parents
  # * update attributes to reflect parent primary keys
  # * persist current object
  # * associate temp id with current object
  # * associate parent objects with current object
  # * process children
  # * associate children
  # * record hooks for later playback
  # * run post-process sideload hooks
  # * return current object
  #
  # @return a model instance
  def run
    attributes = @adapter.persistence_attributes(self, @attributes)

    parents = @adapter.process_belongs_to(self, attributes)
    persisted = persist_object(@meta[:method], attributes)
    @resource.decorate_record(persisted)
    assign_temp_id(persisted, @meta[:temp_id])

    associate_parents(persisted, parents)

    children = @adapter.process_has_many(self, persisted)

    associate_children(persisted, children) unless @meta[:method] == :destroy

    post_process(persisted, parents)
    post_process(persisted, children)
    after_graph_persist = -> { @resource.after_graph_persist(persisted, metadata) }
    add_hook(after_graph_persist, :after_graph_persist)
    before_commit = -> { @resource.before_commit(persisted, metadata) }
    add_hook(before_commit, :before_commit)
    after_commit = -> { @resource.after_commit(persisted, metadata) }
    add_hook(after_commit, :after_commit)
    persisted
  end

  def iterate(only: [], except: [])
    opts = {
      resource: @resource,
      relationships: @relationships
    }.merge(only: only, except: except)

    Graphiti::Util::RelationshipPayload.iterate(**opts) do |x|
      yield x
    end
  end

  private

  def add_hook(prc, lifecycle_event)
    ::Graphiti::Util::TransactionHooksRecorder.add(prc, lifecycle_event)
  end

  def associate_parents(object, parents)
    # No need to associate to destroyed objects
    parents = parents.select { |x| x[:meta][:method] != :destroy }

    parents.each do |x|
      if x[:object] && object
        if x[:meta][:method] == :disassociate
          if x[:sideload].type == :belongs_to
            x[:sideload].disassociate(object, x[:object])
          else
            x[:sideload].disassociate(x[:object], object)
          end
        elsif x[:sideload].type == :belongs_to
          x[:sideload].associate(object, x[:object])
        elsif [:has_many, :many_to_many].include?(x[:sideload].type)
          x[:sideload].associate_all(object, Array(x[:object]))
        else
          x[:sideload].associate(x[:object], object)
        end
      end
    end
  end

  def associate_children(object, children)
    children.each do |x|
      if x[:object] && object
        if x[:meta][:method] == :disassociate
          x[:sideload].disassociate(object, x[:object])
        elsif x[:meta][:method] == :destroy
          if x[:sideload].type == :many_to_many
            x[:sideload].disassociate(object, x[:object])
          end
          # otherwise, no need to disassociate destroyed objects
        elsif [:has_many, :many_to_many].include?(x[:sideload].type)
          x[:sideload].associate_all(object, Array(x[:object]))
        else
          x[:sideload].associate(object, x[:object])
        end
      end
    end
  end

  def persist_object(method, attributes)
    case method
      when :destroy
        call_resource_method(:destroy, attributes[:id], @caller_model)
      when :update, nil, :disassociate
        call_resource_method(:update, attributes, @caller_model)
      else
        call_resource_method(:create, attributes, @caller_model)
    end
  end

  def post_process(caller_model, processed)
    groups = processed.group_by { |x| x[:meta][:method] }
    groups.each_pair do |method, group|
      group.group_by { |g| g[:sideload] }.each_pair do |sideload, members|
        objects = members.map { |x| x[:object] }
        hook = -> { sideload.fire_hooks!(caller_model, objects, method) }
        add_hook(hook, :before_commit)
      end
    end
  end

  def assign_temp_id(object, temp_id)
    object.instance_variable_set(:@_jsonapi_temp_id, temp_id)
  end

  def metadata
    {
      method: @meta[:method],
      temp_id: @meta[:temp_id],
      caller_model: @caller_model,
      attributes: @attributes,
      relationships: @relationships
    }
  end

  def call_resource_method(method_name, attributes, caller_model)
    method = @resource.method(method_name)

    if method.arity == 1
      method.call(attributes)
    else
      method.call(attributes, metadata)
    end
  end
end
