# frozen_string_literal: true

require 'ostruct'

module Aws
  module Record
    module Transactions
      extend ClientConfiguration

      class << self
        # @example Usage Example
        #   class TableOne
        #     include Aws::Record
        #     string_attr :uuid, hash_key: true
        #   end
        #
        #   class TableTwo
        #     include Aws::Record
        #     string_attr :hk, hash_key: true
        #     string_attr :rk, range_key: true
        #   end
        #
        #   results = Aws::Record::Transactions.transact_find(
        #     transact_items: [
        #       TableOne.tfind_opts(key: { uuid: "uuid1234" }),
        #       TableTwo.tfind_opts(key: { hk: "hk1", rk: "rk1"}),
        #       TableTwo.tfind_opts(key: { hk: "hk2", rk: "rk2"})
        #     ]
        #   ) # => results.responses contains nil or marshalled items
        #   results.responses.map { |r| r.class } # [TableOne, TableTwo, TableTwo]
        #
        # Provides support for the
        # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method
        # Aws::DynamoDB::Client#transact_get_item} for aws-record models.
        #
        # This method runs a transactional find across multiple DynamoDB
        # items, including transactions which get items across multiple actual
        # or virtual tables. This call can contain up to 100 item keys.
        #
        # DynamoDB will reject the request if any of the following is true:
        # * A conflicting operation is in the process of updating an item to be read.
        # * There is insufficient provisioned capacity for the transaction to be completed.
        # * There is a user error, such as an invalid data format.
        # * The aggregate size of the items in the transaction cannot exceed 4 MB.
        #
        # @param [Hash] opts Options to pass through to
        #   {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_get_items-instance_method
        #   Aws::DynamoDB::Client#transact_get_items}, with the exception of the
        #   +:transact_items+ array, which uses the {ItemOperations.ItemOperationsClassMethods.tfind_opts #tfind_opts}
        #   operation on your model class to provide extra metadata
        #   used to marshal your items after retrieval.
        # @option opts [Array] :transact_items A set of +#tfind_opts+ results,
        #   such as those created by the usage example.
        # @option opts [Aws::DynamoDB::Client] :client Optionally, you can pass
        #   in your own client to use for the transaction calls.
        # @return [OpenStruct] Structured like the client API response from
        #   +#transact_get_items+, except that the +responses+ member contains
        #   +Aws::Record+ items marshaled into the classes used to call
        #   +#tfind_opts+ in each positional member. See the usage example.
        def transact_find(opts)
          opts = opts.dup
          client = opts.delete(:client) || dynamodb_client
          transact_items = opts.delete(:transact_items) # add nil check?
          model_classes = []
          client_transact_items = transact_items.map do |tfind_opts|
            model_class = tfind_opts.delete(:model_class)
            model_classes << model_class
            tfind_opts
          end
          request_opts = opts
          request_opts[:transact_items] = client_transact_items
          client_resp = client.transact_get_items(
            request_opts
          )
          index = -1
          ret = OpenStruct.new
          ret.consumed_capacity = client_resp.consumed_capacity
          ret.missing_items = []
          ret.responses = client_resp.responses.map do |item|
            index += 1
            if item.nil? || item.item.nil?
              missing_data = {
                model_class: model_classes[index],
                key: transact_items[index][:get][:key]
              }
              ret.missing_items << missing_data
              nil
            else
              # need to translate the item keys
              raw_item = item.item
              model_class = model_classes[index]
              new_item_opts = {}
              raw_item.each do |db_name, value|
                name = model_class.attributes.db_to_attribute_name(db_name)
                new_item_opts[name] = value
              end
              item = model_class.new(new_item_opts)
              item.clean!
              item
            end
          end
          ret
        end

        # @example Usage Example
        #   class TableOne
        #     include Aws::Record
        #     string_attr :uuid, hash_key: true
        #     string_attr :body
        #   end
        #
        #   class TableTwo
        #     include Aws::Record
        #     string_attr :hk, hash_key: true
        #     string_attr :rk, range_key: true
        #     string_attr :body
        #   end
        #
        #   check_exp = TableOne.transact_check_expression(
        #     key: { uuid: "foo" },
        #     condition_expression: "size(#T) <= :v",
        #     expression_attribute_names: {
        #       "#T" => "body"
        #     },
        #     expression_attribute_values: {
        #       ":v" => 1024
        #     }
        #   )
        #   new_item = TableTwo.new(hk: "hk1", rk: "rk1", body: "Hello!")
        #   update_item_1 = TableOne.find(uuid: "bar")
        #   update_item_1.body = "Updated the body!"
        #   put_item = TableOne.new(uuid: "foobar", body: "Content!")
        #   update_item_2 = TableTwo.find(hk: "hk2", rk: "rk2")
        #   update_item_2.body = "Update!"
        #   delete_item = TableOne.find(uuid: "to_be_deleted")
        #
        #   Aws::Record::Transactions.transact_write(
        #     transact_items: [
        #       { check: check_exp },
        #       { save: new_item },
        #       { save: update_item_1 },
        #       {
        #          put: put_item,
        #          condition_expression: "attribute_not_exists(#H)",
        #          expression_attribute_names: { "#H" => "uuid" },
        #          return_values_on_condition_check_failure: "ALL_OLD"
        #       },
        #       { update: update_item_2 },
        #       { delete: delete_item }
        #     ]
        #   )
        #
        # Provides support for the
        # {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method
        # Aws::DynamoDB::Client#transact_write_items} for aws-record models.
        #
        # This method passes in aws-record items into transactional writes,
        # as well as adding the ability to run 'save' commands in a transaction
        # while allowing aws-record to determine if a :put or :update operation
        # is most appropriate. +#transact_write+ supports 5 different transact
        # item modes:
        # - save: Behaves much like the +#save+ operation on the item itself.
        #   If the keys are dirty, and thus it appears to be a new item, will
        #   create a :put operation with a conditional check on the item's
        #   existence. Note that you cannot bring your own conditional
        #   expression in this case. If you wish to force put or add your
        #   own conditional checks, use the :put operation.
        # - put: Does a force put for the given item key and model.
        # - update: Does an upsert for the given item.
        # - delete: Deletes the given item.
        # - check: Takes the result of +#transact_check_expression+,
        #   performing the specified check as a part of the transaction.
        #   See {ItemOperations.ItemOperationsClassMethods.transact_check_expression #transact_check_expression}
        #   for more information.
        #
        # This call contain up to 100 action requests.
        #
        # DynamoDB will reject the request if any of the following is true:
        # * A condition in one of the condition expressions is not met.
        # * An ongoing operation is in the process of updating the same item.
        # * There is insufficient provisioned capacity for the transaction to
        #   be completed.
        # * An item size becomes too large (bigger than 400 KB), a local secondary
        #   index (LSI) becomes too large, or a similar validation error occurs
        #   because of changes made by the transaction.
        # * The aggregate size of the items in the transaction exceeds 4 MB.
        # * There is a user error, such as an invalid data format.
        #
        # @param [Hash] opts Options to pass through to
        #   {https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#transact_write_items-instance_method
        #   Aws::DynamoDB::Client#transact_write_items} with the exception of
        #   :transact_items array, which is transformed to use your item to populate
        #   the :key, :table_name, :item, and/or :update_expression parameters
        #   as appropriate. See the usage example for a comprehensive set of combinations.
        # @option opts [Array] :transact_items An array of hashes, accepting
        #   +:save+, +:put+, +:delete+, +:update+, and +:check+ as specified.
        # @option opts [Aws::DynamoDB::Client] :client Optionally, you can
        #   specify a client to use for this transaction call. If not
        #   specified, the configured client for +Aws::Record::Transactions+
        #   is used.
        def transact_write(opts)
          opts = opts.dup
          client = opts.delete(:client) || dynamodb_client
          dirty_items = []
          delete_items = []
          # fetch abstraction records
          transact_items = _transform_transact_write_items(
            opts.delete(:transact_items),
            dirty_items,
            delete_items
          )
          opts[:transact_items] = transact_items
          resp = client.transact_write_items(opts)
          # mark all items clean/destroyed as needed if we didn't raise an exception
          dirty_items.each(&:clean!)
          delete_items.each { |i| i.instance_variable_get('@data').destroyed = true }
          resp
        end

        private

        def _transform_transact_write_items(transact_items, dirty_items, delete_items)
          transact_items.map do |item|
            # this code will assume users only provided one operation, and
            # will fail down the line if that assumption is wrong
            if (save_record = item.delete(:save))
              dirty_items << save_record
              _transform_save_record(save_record, item)
            elsif (put_record = item.delete(:put))
              dirty_items << put_record
              _transform_put_record(put_record, item)
            elsif (delete_record = item.delete(:delete))
              delete_items << delete_record
              _transform_delete_record(delete_record, item)
            elsif (update_record = item.delete(:update))
              dirty_items << update_record
              _transform_update_record(update_record, item)
            elsif (check_record = item.delete(:check))
              _transform_check_record(check_record, item)
            else
              raise ArgumentError, 'Invalid transact write item, must include an operation of ' \
                                   "type :save, :update, :delete, :update, or :check - #{item}"

            end
          end
        end

        def _transform_save_record(save_record, opts)
          # determine if record is considered a new item or not
          # then create a put with conditions, or an update
          if save_record.send(:expect_new_item?)
            safety_expression = save_record.send(:prevent_overwrite_expression)
            if opts.include?(:condition_expression)
              raise Errors::TransactionalSaveConditionCollision,
                    'Transactional write includes a :save operation that would ' \
                    "result in a 'safe put' for the given item, yet a " \
                    'condition expression was also provided. This is not ' \
                    'currently supported. You should rewrite this case to use ' \
                    'a :put transaction, adding the existence check to your ' \
                    "own condition expression if desired.\n" \
                    "\tItem: #{JSON.pretty_unparse(save_record.to_h)}\n" \
                    "\tExtra Options: #{JSON.pretty_unparse(opts)}"
            else
              opts = opts.merge(safety_expression)
              _transform_put_record(save_record, opts)
            end
          else
            _transform_update_record(save_record, opts)
          end
        end

        def _transform_put_record(put_record, opts)
          # convert to a straight put
          opts[:table_name] = put_record.class.table_name
          opts[:item] = put_record.send(:_build_item_for_save)
          { put: opts }
        end

        def _transform_delete_record(delete_record, opts)
          # extract the key from each record to perform a deletion
          opts[:table_name] = delete_record.class.table_name
          opts[:key] = delete_record.send(:key_values)
          { delete: opts }
        end

        def _transform_update_record(update_record, opts)
          # extract dirty attribute changes to perform an update
          opts[:table_name] = update_record.class.table_name
          opts[:key] = update_record.send(:key_values)
          dirty_changes = update_record.send(:_dirty_changes_for_update)
          update_expression_opts = update_record.class.send(
            :_build_update_expression,
            dirty_changes
          )
          opts = update_record.class.send(
            :_merge_update_expression_opts,
            update_expression_opts,
            opts
          )
          { update: opts }
        end

        def _transform_check_record(check_record, opts)
          # check records are a pass-through
          { condition_check: opts.merge(check_record) }
        end
      end
    end
  end
end
