require 'ransack/context'
require 'polyamorous/polyamorous'

module Ransack
  module Adapters
    module ActiveRecord
      class Context < ::Ransack::Context

        def relation_for(object)
          object.all
        end

        def type_for(attr)
          return nil unless attr && attr.valid?
          relation     = attr.arel_attribute.relation
          name         = attr.arel_attribute.name.to_s
          table        = relation.respond_to?(:table_name) ? relation.table_name : relation.name
          schema_cache = self.klass.connection.schema_cache
          unless schema_cache.send(:data_source_exists?, table)
            raise "No table named #{table} exists."
          end
          column = attr.klass.columns.find { |column| column.name == name }
          column&.type
        end

        def evaluate(search, opts = {})
          viz = Visitor.new
          relation = @object.where(viz.accept(search.base))

          if search.sorts.any?
            relation = relation.except(:order)
            # Rather than applying all of the search's sorts in one fell swoop,
            # as the original implementation does, we apply one at a time.
            #
            # If the sort (returned by the Visitor above) is a symbol, we know
            # that it represents a scope on the model and we can apply that
            # scope.
            #
            # Otherwise, we fall back to the applying the sort with the "order"
            # method as the original implementation did. Actually the original
            # implementation used "reorder," which was overkill since we already
            # have a clean slate after "relation.except(:order)" above.
            viz.accept(search.sorts).each do |scope_or_sort|
              if scope_or_sort.is_a?(Symbol)
                relation = relation.send(scope_or_sort)
              else
                case Ransack.options[:postgres_fields_sort_option]
                when :nulls_first
                  scope_or_sort = scope_or_sort.direction == :asc ? Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST") : Arel.sql("#{scope_or_sort.to_sql} NULLS LAST")
                when :nulls_last
                  scope_or_sort = scope_or_sort.direction == :asc ? Arel.sql("#{scope_or_sort.to_sql} NULLS LAST") : Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST")
                when :nulls_always_first
                  scope_or_sort = Arel.sql("#{scope_or_sort.to_sql} NULLS FIRST")
                when :nulls_always_last
                  scope_or_sort = Arel.sql("#{scope_or_sort.to_sql} NULLS LAST")
                end

                relation = relation.order(scope_or_sort)
              end
            end
          end

          opts[:distinct] ? relation.distinct : relation
        end

        def attribute_method?(str, klass = @klass)
          exists = false
          if ransackable_attribute?(str, klass)
            exists = true
          elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
            remainder = []
            found_assoc = nil
            while !found_assoc && remainder.unshift(segments.pop) &&
            segments.size > 0 do
              assoc, poly_class = unpolymorphize_association(
                segments.join(Constants::UNDERSCORE)
                )
              if found_assoc = get_association(assoc, klass)
                exists = attribute_method?(
                  remainder.join(Constants::UNDERSCORE),
                  poly_class || found_assoc.klass
                  )
              end
            end
          end
          exists
        end

        def table_for(parent)
          parent.table
        end

        def klassify(obj)
          if Class === obj && ::ActiveRecord::Base > obj
            obj
          elsif obj.respond_to? :klass
            obj.klass
          elsif obj.respond_to? :base_klass
            obj.base_klass
          else
            raise ArgumentError, "Don't know how to klassify #{obj}"
          end
        end

        # All dependent Arel::Join nodes used in the search query.
        #
        # This could otherwise be done as `@object.arel.join_sources`, except
        # that ActiveRecord's build_joins sets up its own JoinDependency.
        # This extracts what we need to access the joins using our existing
        # JoinDependency to track table aliases.
        #
        def join_sources
          base, joins = begin
            alias_tracker = @object.alias_tracker
            constraints   = @join_dependency.join_constraints(@object.joins_values, alias_tracker, @object.references_values)

            [
              Arel::SelectManager.new(@object.table),
              constraints
            ]
          end
          joins.each do |aliased_join|
            base.from(aliased_join)
          end
          base.join_sources
        end

        def alias_tracker
          @join_dependency.send(:alias_tracker)
        end

        def lock_association(association)
          @lock_associations << association
        end

        def remove_association(association)
          return if @lock_associations.include?(association)
          @join_dependency.instance_variable_get(:@join_root).children.delete_if { |stashed|
            stashed.eql?(association)
          }
          @object.joins_values.delete_if { |jd|
            jd.instance_variables.include?(:@join_root) &&
            jd.instance_variable_get(:@join_root).children.map(&:object_id) == [association.object_id]
          }
        end

        # Build an Arel subquery that selects keys for the top query,
        # drawn from the first join association's foreign_key.
        #
        # Example: for an Article that has_and_belongs_to_many Tags
        #
        #   context = Article.search.context
        #   attribute = Attribute.new(context, "tags_name").tap do |a|
        #     context.bind(a, a.name)
        #   end
        #   context.build_correlated_subquery(attribute.parent).to_sql
        #
        #   # SELECT "articles_tags"."article_id" FROM "articles_tags"
        #   # INNER JOIN "tags" ON "tags"."id" = "articles_tags"."tag_id"
        #   # WHERE "articles_tags"."article_id" = "articles"."id"
        #
        # The WHERE condition on this query makes it invalid by itself,
        # because it is correlated to the primary key on the outer query.
        #
        def build_correlated_subquery(association)
          join_constraints = extract_joins(association)
          join_root = join_constraints.shift
          correlated_key = extract_correlated_key(join_root)
          subquery = Arel::SelectManager.new(association.base_klass)
          subquery.from(join_root.left)
          subquery.project(correlated_key)
          join_constraints.each do |j|
            subquery.join_sources << Arel::Nodes::InnerJoin.new(j.left, j.right)
          end

          # Handle polymorphic associations where correlated_key is an array
          if correlated_key.is_a?(Array)
            # For polymorphic associations, we need to add conditions for both the foreign key and type
            correlated_key.each_with_index do |key, index|
              if index == 0
                # This is the foreign key
                subquery = subquery.where(key.eq(primary_key))
              else
                # This is the type key, which should be equal to the model name
                subquery = subquery.where(key.eq(@klass.name))
              end
            end
          else
            # Original behavior for non-polymorphic associations
            subquery = subquery.where(correlated_key.eq(primary_key))
          end

          subquery
        end

        def primary_key
          @object.table[@object.primary_key]
        end

        private

        def extract_correlated_key(join_root)
          case join_root
          when Arel::Nodes::OuterJoin
            # one of join_root.right/join_root.left is expected to be Arel::Nodes::On
            if join_root.right.is_a?(Arel::Nodes::On)
              extract_correlated_key(join_root.right.expr)
            elsif join_root.left.is_a?(Arel::Nodes::On)
              extract_correlated_key(join_root.left.expr)
            else
              raise 'Ransack encountered an unexpected arel structure'
            end
          when Arel::Nodes::Equality
            pk = primary_key
            if join_root.left == pk
              join_root.right
            elsif join_root.right == pk
              join_root.left
            else
              nil
            end
          when Arel::Nodes::And
            # And may have multiple children, so we need to check all, not via left/right
            if join_root.children.any?
              join_root.children.each do |child|
                key = extract_correlated_key(child)
                return key if key
              end
            else
              extract_correlated_key(join_root.left) || extract_correlated_key(join_root.right)
            end
          else
            # eg parent was Arel::Nodes::And and the evaluated side was one of
            # Arel::Nodes::Grouping or MultiTenant::TenantEnforcementClause
            nil
          end
        end

        def get_parent_and_attribute_name(str, parent = @base)
          attr_name = nil

          if ransackable_attribute?(str, klassify(parent))
            attr_name = str
          elsif (segments = str.split(Constants::UNDERSCORE)).size > 1
            remainder = []
            found_assoc = nil
            while remainder.unshift(segments.pop) && segments.size > 0 &&
            !found_assoc do
              assoc, klass = unpolymorphize_association(
                segments.join(Constants::UNDERSCORE)
                )
              if found_assoc = get_association(assoc, parent)
                join = build_or_find_association(
                  found_assoc.name, parent, klass
                  )
                parent, attr_name = get_parent_and_attribute_name(
                  remainder.join(Constants::UNDERSCORE), join
                  )
              end
            end
          end

          [parent, attr_name]
        end

        def get_association(str, parent = @base)
          klass = klassify parent
          ransackable_association?(str, klass) &&
          klass.reflect_on_all_associations.detect { |a| a.name.to_s == str }
        end

        def join_dependency(relation)
          if relation.respond_to?(:join_dependency) # Polyamorous enables this
            relation.join_dependency
          else
            build_joins(relation)
          end
        end

        # Checkout active_record/relation/query_methods.rb +build_joins+ for
        # reference. Lots of duplicated code maybe we can avoid it
        def build_joins(relation)
          buckets = relation.joins_values + relation.left_outer_joins_values

          buckets = buckets.group_by do |join|
            case join
            when String
              :string_join
            when Hash, Symbol, Array
              :association_join
            when Polyamorous::JoinDependency, Polyamorous::JoinAssociation
              :stashed_join
            when Arel::Nodes::Join
              :join_node
            else
              raise 'unknown class: %s' % join.class.name
            end
          end
          buckets.default = []
          association_joins         = buckets[:association_join]
          stashed_association_joins = buckets[:stashed_join]
          join_nodes                = buckets[:join_node].uniq
          string_joins              = buckets[:string_join].map(&:strip)
          string_joins.uniq!

          join_list = join_nodes + convert_join_strings_to_ast(relation.table, string_joins)

          alias_tracker = relation.alias_tracker(join_list)
          join_dependency = Polyamorous::JoinDependency.new(relation.klass, relation.table, association_joins, Arel::Nodes::OuterJoin)
          join_dependency.instance_variable_set(:@alias_tracker, alias_tracker)
          join_nodes.each do |join|
            join_dependency.send(:alias_tracker).aliases[join.left.name.downcase] = 1
          end
          join_dependency
        end

        def convert_join_strings_to_ast(table, joins)
          joins.map! { |join| table.create_string_join(Arel.sql(join)) unless join.blank? }
          joins.compact!
          joins
        end

        def build_or_find_association(name, parent = @base, klass = nil)
          find_association(name, parent, klass) or build_association(name, parent, klass)
        end

        def find_association(name, parent = @base, klass = nil)
          @join_dependency.instance_variable_get(:@join_root).children.detect do |assoc|
            assoc.reflection.name == name && assoc.table &&
            (@associations_pot.empty? || @associations_pot[assoc] == parent || !@associations_pot.key?(assoc)) &&
            (!klass || assoc.reflection.klass == klass)
          end
        end

        def build_association(name, parent = @base, klass = nil)
          jd = Polyamorous::JoinDependency.new(
            parent.base_klass,
            parent.table,
            Polyamorous::Join.new(name, @join_type, klass),
            @join_type
          )
          found_association = jd.instance_variable_get(:@join_root).children.last

          @associations_pot[found_association] = parent

          # TODO maybe we dont need to push associations here, we could loop
          # through the @associations_pot instead
          @join_dependency.instance_variable_get(:@join_root).children.push found_association

          # Builds the arel nodes properly for this association
          @tables_pot[found_association] = @join_dependency.construct_tables_for_association!(jd.instance_variable_get(:@join_root), found_association)

          # Leverage the stashed association functionality in AR
          @object = @object.joins(jd)
          found_association
        end

        def extract_joins(association)
          parent = @join_dependency.instance_variable_get(:@join_root)
          reflection = association.reflection
          join_constraints = association.join_constraints_with_tables(
                               parent.table,
                               parent.base_klass,
                               Arel::Nodes::OuterJoin,
                               @join_dependency.instance_variable_get(:@alias_tracker),
                               @tables_pot[association]
                             )
          join_constraints.to_a.flatten
        end
      end
    end
  end
end
