module JsonapiCompliable
  # @attr_reader [Symbol] name The name of the sideload
  # @attr_reader [Class] resource_class The corresponding Resource class
  # @attr_reader [Boolean] polymorphic Is this a polymorphic sideload?
  # @attr_reader [Hash] polymorphic_groups The subgroups, when polymorphic
  # @attr_reader [Hash] sideloads The associated sibling sideloads
  # @attr_reader [Proc] scope_proc The configured 'scope' block
  # @attr_reader [Proc] assign_proc The configured 'assign' block
  # @attr_reader [Symbol] grouping_field The configured 'group_by' symbol
  # @attr_reader [Symbol] foreign_key The attribute used to match objects - need not be a true database foreign key.
  # @attr_reader [Symbol] primary_key The attribute used to match objects - need not be a true database primary key.
  # @attr_reader [Symbol] type One of :has_many, :belongs_to, etc
  class Sideload
    attr_reader :name,
      :resource_class,
      :polymorphic,
      :polymorphic_groups,
      :parent,
      :sideloads,
      :scope_proc,
      :assign_proc,
      :grouping_field,
      :foreign_key,
      :primary_key,
      :type

    # NB - the adapter's +#sideloading_module+ is mixed in on instantiation
    #
    # An anonymous Resource will be assigned when none provided.
    #
    # @see Adapters::Abstract#sideloading_module
    def initialize(name, type: nil, resource: nil, polymorphic: false, primary_key: :id, foreign_key: nil, parent: nil)
      @name               = name
      @resource_class     = (resource || Class.new(Resource))
      @sideloads          = {}
      @polymorphic        = !!polymorphic
      @polymorphic_groups = {} if polymorphic?
      @parent             = parent
      @primary_key        = primary_key
      @foreign_key        = foreign_key
      @type               = type

      extend @resource_class.config[:adapter].sideloading_module
    end

    # @see #resource_class
    # @return [Resource] an instance of +#resource_class+
    def resource
      @resource ||= resource_class.new
    end

    # Is this sideload polymorphic?
    #
    # Polymorphic sideloads group the parent objects in some fashion,
    # so different 'types' can be resolved differently. Let's say an
    # +Office+ has a polymorphic +Organization+, which can be either a
    # +Business+ or +Government+:
    #
    #   allow_sideload :organization, :polymorphic: true do
    #     group_by :organization_type
    #
    #     allow_sideload 'Business', resource: BusinessResource do
    #       # ... code ...
    #     end
    #
    #     allow_sideload 'Governemnt', resource: GovernmentResource do
    #       # ... code ...
    #     end
    #   end
    #
    # You probably want to extract this code into an Adapter. For instance,
    # with ActiveRecord:
    #
    #   polymorphic_belongs_to :organization,
    #     group_by: :organization_type,
    #     groups: {
    #       'Business' => {
    #         scope: -> { Business.all },
    #         resource: BusinessResource,
    #         foreign_key: :organization_id
    #       },
    #       'Government' => {
    #         scope: -> { Government.all },
    #         resource: GovernmentResource,
    #         foreign_key: :organization_id
    #       }
    #     }
    #
    # @see Adapters::ActiveRecordSideloading#polymorphic_belongs_to
    # @return [Boolean] is this sideload polymorphic?
    def polymorphic?
      @polymorphic == true
    end

    # Build a scope that will be used to fetch the related records
    # This scope will be further chained with filtering/sorting/etc
    #
    # You probably want to wrap this logic in an Adapter, instead of
    # specifying in your resource directly.
    #
    # @example Default ActiveRecord
    #   class PostResource < ApplicationResource
    #     # ... code ...
    #     allow_sideload :comments, resource: CommentResource do
    #       scope do |posts|
    #         Comment.where(post_id: posts.map(&:id))
    #       end
    #       # ... code ...
    #     end
    #   end
    #
    # @example Custom Scope
    #   # In this example, our base scope is a Hash
    #   scope do |posts|
    #     { post_ids: posts.map(&:id) }
    #   end
    #
    # @example ActiveRecord via Adapter
    #   class PostResource < ApplicationResource
    #     # ... code ...
    #     has_many :comments,
    #       scope: -> { Comment.all },
    #       resource: CommentResource,
    #       foreign_key: :post_id
    #   end
    #
    # @see Adapters::Abstract
    # @see Adapters::ActiveRecordSideloading#has_many
    # @see #allow_sideload
    # @yieldparam parents - The resolved parent records
    def scope(&blk)
      @scope_proc = blk
    end

    # The proc used to assign the resolved parents and children.
    #
    # You probably want to wrap this logic in an Adapter, instead of
    # specifying in your resource directly.
    #
    # @example Default ActiveRecord
    #   class PostResource < ApplicationResource
    #     # ... code ...
    #     allow_sideload :comments, resource: CommentResource do
    #       # ... code ...
    #       assign do |posts, comments|
    #         posts.each do |post|
    #           relevant_comments = comments.select { |c| c.post_id == post.id }
    #           post.comments = relevant_comments
    #         end
    #       end
    #     end
    #   end
    #
    # @example ActiveRecord via Adapter
    #   class PostResource < ApplicationResource
    #     # ... code ...
    #     has_many :comments,
    #       scope: -> { Comment.all },
    #       resource: CommentResource,
    #       foreign_key: :post_id
    #   end
    #
    # @see Adapters::Abstract
    # @see Adapters::ActiveRecordSideloading#has_many
    # @see #allow_sideload
    # @yieldparam parents - The resolved parent records
    # @yieldparam children - The resolved child records
    def assign(&blk)
      @assign_proc = blk
    end

    # Configure how to associate parent and child records.
    # Delegates to #resource
    #
    # @see #name
    # @see #type
    # @api private
    def associate(parent, child)
      association_name = @parent ? @parent.name : name
      resource.associate(parent, child, association_name, type)
    end

    # Configure how to disassociate parent and child records.
    # Delegates to #resource
    #
    # @see #name
    # @see #type
    # @api private
    def disassociate(parent, child)
      association_name = @parent ? @parent.name : name
      resource.disassociate(parent, child, association_name, type)
    end

    # Define an attribute that groups the parent records. For instance, with
    # an ActiveRecord polymorphic belongs_to there will be a +parent_id+
    # and +parent_type+. We would want to group on +parent_type+:
    #
    #  allow_sideload :organization, polymorphic: true do
    #    # group parent_type, parent here is 'organization'
    #    group_by :organization_type
    #  end
    #
    # @see #polymorphic?
    def group_by(grouping_field)
      @grouping_field = grouping_field
    end

    # Resolve the sideload.
    #
    # * Uses the 'scope' proc to build a 'base scope'
    # * Chains additional criteria onto that 'base scope'
    # * Resolves that scope (see Scope#resolve)
    # * Assigns the resulting child objects to their corresponding parents
    #
    # @see Scope#resolve
    # @param [Object] parents The resolved parent models
    # @param [Query] query The Query instance
    # @param [Symbol] namespace The current namespace (see Resource#with_context)
    # @see Query
    # @see Resource#with_context
    # @return [void]
    # @api private
    def resolve(parents, query, namespace = nil)
      namespace ||= name

      if polymorphic?
        resolve_polymorphic(parents, query)
      else
        resolve_basic(parents, query, namespace)
      end
    end

    # Configure a relationship between Resource objects
    #
    # You probably want to extract this logic into an adapter
    # rather than using directly
    #
    # @example Default ActiveRecord
    #   # What happens 'under the hood'
    #   class CommentResource < ApplicationResource
    #     # ... code ...
    #     allow_sideload :post, resource: PostResource do
    #       scope do |comments|
    #         Post.where(id: comments.map(&:post_id))
    #       end
    #
    #       assign do |comments, posts|
    #         comments.each do |comment|
    #           relevant_post = posts.find { |p| p.id == comment.post_id }
    #           comment.post = relevant_post
    #         end
    #       end
    #     end
    #   end
    #
    #   # Rather than writing that code directly, go through the adapter:
    #   class CommentResource < ApplicationResource
    #     # ... code ...
    #     use_adapter JsonapiCompliable::Adapters::ActiveRecord
    #
    #     belongs_to :post,
    #       scope: -> { Post.all },
    #       resource: PostResource,
    #       foreign_key: :post_id
    #   end
    #
    # @see Adapters::ActiveRecordSideloading#belongs_to
    # @see #assign
    # @see #scope
    # @return void
    def allow_sideload(name, opts = {}, &blk)
      sideload = Sideload.new(name, opts)
      sideload.instance_eval(&blk) if blk

      if polymorphic?
        @polymorphic_groups[name] = sideload
      else
        @sideloads[name] = sideload
      end
    end

    # Fetch a Sideload object by its name
    # @param [Symbol] name The name of the corresponding sideload
    # @see +allow_sideload
    # @return the corresponding Sideload object
    def sideload(name)
      @sideloads[name]
    end

    # Looks at all nested sideload, and all nested sideloads for the
    # corresponding Resources, and returns an Include Directive hash
    #
    # For instance, this configuration:
    #
    #   class BarResource < ApplicationResource
    #     allow_sideload :baz do
    #     end
    #   end
    #
    #   class PostResource < ApplicationResource
    #     allow_sideload :foo do
    #       allow_sideload :bar, resource: BarResource do
    #       end
    #     end
    #   end
    #
    # +post_resource.sideloading.to_hash+ would return
    #
    #   { base: { foo: { bar: { baz: {} } } } }
    #
    # @return [Hash] The nested include hash
    # @api private
    def to_hash(processed = [])
      # Cut off at 5 recursions
      if processed.select { |p| p == self }.length == 5
        return { name => {} }
      end
      processed << self

      result = { name => {} }.tap do |hash|
        @sideloads.each_pair do |key, sideload|
          hash[name][key] = sideload.to_hash(processed)[key] || {}

          if sideload.polymorphic?
            sideload.polymorphic_groups.each_pair do |type, sl|
              hash[name][key].merge!(nested_sideload_hash(sl, processed))
            end
          else
            hash[name][key].merge!(nested_sideload_hash(sideload, processed))
          end
        end
      end
      result
    end

    # @api private
    def polymorphic_child_for_type(type)
      polymorphic_groups.values.find do |v|
        v.resource_class.config[:type] == type.to_sym
      end
    end

    private

    def nested_sideload_hash(sideload, processed)
      {}.tap do |hash|
        if sideloading = sideload.resource_class.sideloading
          hash.merge!(sideloading.to_hash(processed)[:base])
        end
      end
    end

    def polymorphic_grouper(grouping_field)
      lambda do |record|
        if record.is_a?(Hash)
          if record.keys[0].is_a?(Symbol)
            record[grouping_field]
          else
            record[grouping_field.to_s]
          end
        else
          record.send(grouping_field)
        end
      end
    end

    def resolve_polymorphic(parents, query)
      grouper = polymorphic_grouper(@grouping_field)

      parents.group_by(&grouper).each_pair do |group_type, group_members|
        sideload_for_group = @polymorphic_groups[group_type]
        if sideload_for_group
          sideload_for_group.resolve(group_members, query, name)
        end
      end
    end

    def resolve_basic(parents, query, namespace)
      sideload_scope   = scope_proc.call(parents)
      sideload_scope   = Scope.new(sideload_scope, resource_class.new, query, default_paginate: false, namespace: namespace)
      sideload_results = sideload_scope.resolve
      assign_proc.call(parents, sideload_results)
    end
  end
end