# frozen-string-literal: true

module Sequel
  module Plugins
    # The association_pks plugin adds association_pks, association_pks=, and
    # association_pks_dataset instance methods to the model class for each
    # one_to_many and many_to_many association added.  These methods allow for
    # easily returning the primary keys of the associated objects, and easily
    # modifying which objects are associated:
    #
    #   Artist.one_to_many :albums
    #   artist = Artist[1]
    #   artist.album_pks_dataset
    #   # SELECT id FROM albums WHERE (albums.artist_id = 1)
    #
    #   artist.album_pks # [1, 2, 3]
    #   artist.album_pks = [2, 4]
    #   artist.album_pks # [2, 4]
    #   artist.save
    #   # Persist changes
    #
    # Note that it uses the singular form of the association name. Also note
    # that the setter both associates to new primary keys not in the assocation
    # and disassociates from primary keys not provided to the method.
    #
    # This plugin makes modifications directly to the underlying tables,
    # it does not create or return any model objects, and therefore does
    # not call any callbacks.  If you have any association callbacks,
    # you probably should not use the setter methods this plugin adds.
    #
    # By default, changes to the association will not happen until the object
    # is saved.  However, using the delay_pks: false association option, you can have
    # the changes made immediately when the association_pks setter method is called.
    #
    # By default, repeated calls to the association_pks getter method will not be
    # cached, unless the setter method has been used and the delay_pks: false
    # association option is not used.  You can set caching of repeated calls to the
    # association_pks getter method using the :cache_pks association option.  You can
    # pass the :refresh option when calling the getter method to ignore any existing
    # cached values, similar to how the :refresh option works with associations.
    #
    # By default, if you pass a nil value to the setter, an exception will be raised.
    # You can change this behavior by using the :association_pks_nil association option.
    # If set to :ignore, the setter will take no action if nil is given.
    # If set to :remove, the setter will treat the nil as an empty array, removing
    # the association all currently associated values.
    #
    # For many_to_many associations, association_pks assumes the related pks can be
    # accessed directly from the join table.  This works in most cases, but in cases
    # where the :right_primary_key association option is used to specify a different
    # primary key in the associated table, association_pks will return the value of
    # the association primary keys (foreign key values to associated table in the join
    # table), not the associated model primary keys.  If you would like to use the
    # associated model primary keys, you need to use the
    # :association_pks_use_associated_table association option. If the
    # :association_pks_use_associated_table association option is used, no setter
    # method will be added.
    #
    # Usage:
    #
    #   # Make all model subclass *_to_many associations have association_pks
    #   # methods (called before loading subclasses)
    #   Sequel::Model.plugin :association_pks
    #
    #   # Make the Album *_to_many associations have association_pks
    #   # methods (called before the association methods)
    #   Album.plugin :association_pks
    module AssociationPks
      module ClassMethods
        Plugins.model_instance_variables(self, :@_association_pks)

        private

        # Define a association_pks method using the block for the association reflection 
        def def_association_pks_methods(opts)
          association_module_def(opts[:pks_dataset_method], &opts[:pks_dataset])

          opts[:pks_getter_method] = :"#{singularize(opts[:name])}_pks_getter"
          association_module_def(opts[:pks_getter_method], &opts[:pks_getter])
          association_module_def(:"#{singularize(opts[:name])}_pks", opts){|dynamic_opts=OPTS| _association_pks_getter(opts, dynamic_opts)}

          if opts[:pks_setter]
            opts[:pks_setter_method] = :"#{singularize(opts[:name])}_pks_setter"
            association_module_def(opts[:pks_setter_method], &opts[:pks_setter])
            association_module_def(:"#{singularize(opts[:name])}_pks=", opts){|pks| _association_pks_setter(opts, pks)}
          end
        end

        # Add a getter that checks the join table for matching records and
        # a setter that deletes from or inserts into the join table.
        def def_many_to_many(opts)
          super

          return if opts[:type] == :one_through_one

          # Grab values from the reflection so that the hash lookup only needs to be
          # done once instead of inside every method call.
          lk, lpk, rk = opts.values_at(:left_key, :left_primary_key, :right_key)
          clpk = lpk.is_a?(Array)
          crk = rk.is_a?(Array)

          dataset_method = opts[:pks_dataset_method] = :"#{singularize(opts[:name])}_pks_dataset"

          opts[:pks_dataset] = if join_associated_table = opts[:association_pks_use_associated_table]
            tname = opts[:join_table]
            lambda do
              cond = if clpk
                lk.zip(lpk).map{|k, pk| [Sequel.qualify(tname, k), get_column_value(pk)]}
              else
                {Sequel.qualify(tname, lk) => get_column_value(lpk)}
              end
              rpk = opts.associated_class.primary_key
              opts.associated_dataset.
                naked.where(cond).
                select(*Sequel.public_send(rpk.is_a?(Array) ? :deep_qualify : :qualify, opts.associated_class.table_name, rpk))
            end
          elsif clpk
            lambda do
              cond = lk.zip(lpk).map{|k, pk| [k, get_column_value(pk)]}
              _join_table_dataset(opts).where(cond).select(*rk)
            end
          else
            lambda do
              _join_table_dataset(opts).where(lk=>get_column_value(lpk)).select(*rk)
            end
          end

          opts[:pks_getter] = if join_associated_table = opts[:association_pks_use_associated_table]
            lambda do
              public_send(dataset_method).map(opts.associated_class.primary_key)
            end
          else
            lambda do
              public_send(dataset_method).map(rk)
            end
          end

          if !opts[:read_only] && !join_associated_table
            opts[:pks_setter] = lambda do |pks|
              if pks.empty?
                public_send(opts[:remove_all_method])
              else
                checked_transaction do
                  if clpk
                    lpkv = lpk.map{|k| get_column_value(k)}
                    cond = lk.zip(lpkv)
                  else
                    lpkv = get_column_value(lpk)
                    cond = {lk=>lpkv}
                  end
                  ds = _join_table_dataset(opts).where(cond)
                  ds.exclude(rk=>pks).delete
                  pks -= ds.select_map(rk)
                  lpkv = Array(lpkv)
                  key_array = crk ? pks.map{|pk| lpkv + pk} : pks.map{|pk| lpkv + [pk]}
                  key_columns = Array(lk) + Array(rk)
                  ds.import(key_columns, key_array)
                end
              end
            end
          end

          def_association_pks_methods(opts)
        end

        # Add a getter that checks the association dataset and a setter
        # that updates the associated table.
        def def_one_to_many(opts)
          super

          return if opts[:type] == :one_to_one

          key = opts[:key]

          dataset_method = opts[:pks_dataset_method] = :"#{singularize(opts[:name])}_pks_dataset"

          opts[:pks_dataset] = lambda do
            public_send(opts[:dataset_method]).select(*opts.associated_class.primary_key)
          end

          opts[:pks_getter] = lambda do
            public_send(dataset_method).map(opts.associated_class.primary_key)
          end

          unless opts[:read_only]
            opts[:pks_setter] = lambda do |pks|
              if pks.empty?
                public_send(opts[:remove_all_method])
              else
                primary_key = opts.associated_class.primary_key
                pkh = {primary_key=>pks}

                if key.is_a?(Array)
                  h = {}
                  nh = {}
                  key.zip(pk).each do|k, v|
                    h[k] = v
                    nh[k] = nil
                  end
                else
                  h = {key=>pk}
                  nh = {key=>nil}
                end

                checked_transaction do
                  ds = public_send(opts.dataset_method)
                  ds.unfiltered.where(pkh).update(h)
                  ds.exclude(pkh).update(nh)
                end
              end
            end
          end

          def_association_pks_methods(opts)
        end
      end

      module InstanceMethods
        # After creating an object, if there are any saved association pks,
        # call the related association pks setters.
        def after_save
          if assoc_pks = @_association_pks
            assoc_pks.each do |name, pks|
             # pks_setter_method is private
              send(model.association_reflection(name)[:pks_setter_method], pks)
            end
            @_association_pks = nil
          end
          super
        end

        # Clear the associated pks if explicitly refreshing.
        def refresh
          @_association_pks = nil
          super
        end

        private

        # Return the primary keys of the associated objects.
        # If the receiver is a new object, return any saved
        # pks, or an empty array if no pks have been saved.
        def _association_pks_getter(opts, dynamic_opts=OPTS)
          do_cache = opts[:cache_pks]
          delay = opts.fetch(:delay_pks, true)
          cache_or_delay = do_cache || delay

          if dynamic_opts[:refresh] && @_association_pks
            @_association_pks.delete(opts[:name])
          end

          if new? && cache_or_delay
            (@_association_pks ||= {})[opts[:name]] ||= []
          elsif cache_or_delay && @_association_pks && (objs = @_association_pks[opts[:name]])
            objs
          elsif do_cache
           # pks_getter_method is private
            (@_association_pks ||= {})[opts[:name]] = send(opts[:pks_getter_method])
          else
           # pks_getter_method is private
            send(opts[:pks_getter_method])
          end
        end

        # Update which objects are associated to the receiver.
        # If the receiver is a new object, save the pks
        # so the update can happen after the receiver has been saved.
        def _association_pks_setter(opts, pks)
          if pks.nil?
            case opts[:association_pks_nil]
            when :remove
              pks = []
            when :ignore
              return
            else
              raise Error, "nil value given to association_pks setter"
            end
          end

          pks = convert_pk_array(opts, pks)

          if opts.fetch(:delay_pks, true)
            modified!
            (@_association_pks ||= {})[opts[:name]] = pks
          else
            # pks_setter_method is private
            send(opts[:pks_setter_method], pks)
          end
        end

        # If the associated class's primary key column type is integer,
        # typecast all provided values to integer before using them.
        def convert_pk_array(opts, pks)
          klass = opts.associated_class
          primary_key = klass.primary_key
          sch = klass.db_schema

          if primary_key.is_a?(Array)
            if (cols = sch.values_at(*klass.primary_key)).all? && (cols.map{|c| c[:type] == :integer}).all?
              db = model.db
              pks.map do |cpk|
                cpk.map do |pk|
                  db.typecast_value(:integer, pk)
                end
              end
            else
              pks
            end
          elsif (col = sch[klass.primary_key]) && (col[:type] == :integer)
            pks.map{|pk| model.db.typecast_value(:integer, pk)}
          else
            pks
          end
        end
      end
    end
  end
end
