# frozen_string_literal: true

module Stripe
  class StripeObject
    include Enumerable

    @@permanent_attributes = Set.new([:id]) # rubocop:disable Style/ClassVars

    # The default :id method is deprecated and isn't useful to us
    undef :id if method_defined?(:id)

    # Sets the given parameter name to one which is known to be an additive
    # object.
    #
    # Additive objects are subobjects in the API that don't have the same
    # semantics as most subobjects, which are fully replaced when they're set.
    # This is best illustrated by example. The `source` parameter sent when
    # updating a subscription is *not* additive; if we set it:
    #
    #     source[object]=card&source[number]=123
    #
    # We expect the old `source` object to have been overwritten completely. If
    # the previous source had an `address_state` key associated with it and we
    # didn't send one this time, that value of `address_state` is gone.
    #
    # By contrast, additive objects are those that will have new data added to
    # them while keeping any existing data in place. The only known case of its
    # use is for `metadata`, but it could in theory be more general. As an
    # example, say we have a `metadata` object that looks like this on the
    # server side:
    #
    #     metadata = { old: "old_value" }
    #
    # If we update the object with `metadata[new]=new_value`, the server side
    # object now has *both* fields:
    #
    #     metadata = { old: "old_value", new: "new_value" }
    #
    # This is okay in itself because usually users will want to treat it as
    # additive:
    #
    #     obj.metadata[:new] = "new_value"
    #     obj.save
    #
    # However, in other cases, they may want to replace the entire existing
    # contents:
    #
    #     obj.metadata = { new: "new_value" }
    #     obj.save
    #
    # This is where things get a little bit tricky because in order to clear
    # any old keys that may have existed, we actually have to send an explicit
    # empty string to the server. So the operation above would have to send
    # this form to get the intended behavior:
    #
    #     metadata[old]=&metadata[new]=new_value
    #
    # This method allows us to track which parameters are considered additive,
    # and lets us behave correctly where appropriate when serializing
    # parameters to be sent.
    def self.additive_object_param(name)
      @additive_params ||= Set.new
      @additive_params << name
    end

    # Returns whether the given name is an additive object parameter. See
    # `.additive_object_param` for details.
    def self.additive_object_param?(name)
      @additive_params ||= Set.new
      @additive_params.include?(name)
    end

    def initialize(id = nil, opts = {})
      id, @retrieve_params = Util.normalize_id(id)
      @opts = Util.normalize_opts(opts)
      @original_values = {}
      @values = {}
      # This really belongs in APIResource, but not putting it there allows us
      # to have a unified inspect method
      @unsaved_values = Set.new
      @transient_values = Set.new
      @values[:id] = id if id
    end

    def self.construct_from(values, opts = {})
      values = Stripe::Util.symbolize_names(values)

      # work around protected #initialize_from for now
      new(values[:id]).send(:initialize_from, values, opts)
    end

    # Determines the equality of two Stripe objects. Stripe objects are
    # considered to be equal if they have the same set of values and each one
    # of those values is the same.
    def ==(other)
      other.is_a?(StripeObject) &&
        @values == other.instance_variable_get(:@values)
    end

    # Hash equality. As with `#==`, we consider two equivalent Stripe objects
    # equal.
    def eql?(other)
      # Defer to the implementation on `#==`.
      self == other
    end

    # As with equality in `#==` and `#eql?`, we hash two Stripe objects to the
    # same value if they're equivalent objects.
    def hash
      @values.hash
    end

    # Indicates whether or not the resource has been deleted on the server.
    # Note that some, but not all, resources can indicate whether they have
    # been deleted.
    def deleted?
      @values.fetch(:deleted, false)
    end

    def to_s(*_args)
      JSON.pretty_generate(to_hash)
    end

    def inspect
      id_string = respond_to?(:id) && !id.nil? ? " id=#{id}" : ""
      "#<#{self.class}:0x#{object_id.to_s(16)}#{id_string}> JSON: " +
        JSON.pretty_generate(@values)
    end

    # Mass assigns attributes on the model.
    #
    # This is a version of +update_attributes+ that takes some extra options
    # for internal use.
    #
    # ==== Attributes
    #
    # * +values+ - Hash of values to use to update the current attributes of
    #   the object. If you are on ruby 2.7 or higher make sure to wrap in curly
    #   braces to be ruby 3 compatible.
    # * +opts+ - Options for +StripeObject+ like an API key that will be reused
    #   on subsequent API calls.
    #
    # ==== Options
    #
    # * +:dirty+ - Whether values should be initiated as "dirty" (unsaved) and
    #   which applies only to new StripeObjects being initiated under this
    #   StripeObject. Defaults to true.
    def update_attributes(values, opts = {}, dirty: true)
      values.each do |k, v|
        add_accessors([k], values) unless metaclass.method_defined?(k.to_sym)
        @values[k] = Util.convert_to_stripe_object(v, opts)
        dirty_value!(@values[k]) if dirty
        @unsaved_values.add(k)
      end
    end

    def [](key)
      @values[key.to_sym]
    end

    def []=(key, value)
      send(:"#{key}=", value)
    end

    def keys
      @values.keys
    end

    def values
      @values.values
    end

    def to_json(*_opts)
      # TODO: pass opts to JSON.generate?
      JSON.generate(@values)
    end

    def as_json(*opts)
      @values.as_json(*opts)
    end

    def to_hash
      maybe_to_hash = lambda do |value|
        return nil if value.nil?

        value.respond_to?(:to_hash) ? value.to_hash : value
      end

      @values.each_with_object({}) do |(key, value), acc|
        acc[key] = case value
                   when Array
                     value.map(&maybe_to_hash)
                   else
                     maybe_to_hash.call(value)
                   end
      end
    end

    def each(&blk)
      @values.each(&blk)
    end

    # Sets all keys within the StripeObject as unsaved so that they will be
    # included with an update when #serialize_params is called. This method is
    # also recursive, so any StripeObjects contained as values or which are
    # values in a tenant array are also marked as dirty.
    def dirty!
      @unsaved_values = Set.new(@values.keys)
      @values.each_value do |v|
        dirty_value!(v)
      end
    end

    # Implements custom encoding for Ruby's Marshal. The data produced by this
    # method should be comprehendable by #marshal_load.
    #
    # This allows us to remove certain features that cannot or should not be
    # serialized.
    def marshal_dump
      # The StripeClient instance in @opts is not serializable and is not
      # really a property of the StripeObject, so we exclude it when
      # dumping
      opts = @opts.clone
      opts.delete(:client)
      [@values, opts]
    end

    # Implements custom decoding for Ruby's Marshal. Consumes data that's
    # produced by #marshal_dump.
    def marshal_load(data)
      values, opts = data
      initialize(values[:id])
      initialize_from(values, opts)
    end

    def serialize_params(options = {})
      update_hash = {}

      @values.each do |k, v|
        # There are a few reasons that we may want to add in a parameter for
        # update:
        #
        #   1. The `force` option has been set.
        #   2. We know that it was modified.
        #   3. Its value is a StripeObject. A StripeObject may contain modified
        #      values within in that its parent StripeObject doesn't know about.
        #
        unsaved = @unsaved_values.include?(k)
        next unless options[:force] || unsaved || v.is_a?(StripeObject)

        update_hash[k.to_sym] = serialize_params_value(
          @values[k], @original_values[k], unsaved, options[:force], key: k
        )
      end

      # a `nil` that makes it out of `#serialize_params_value` signals an empty
      # value that we shouldn't appear in the serialized form of the object
      update_hash.reject! { |_, v| v.nil? }

      update_hash
    end

    # A protected field is one that doesn't get an accessor assigned to it
    # (i.e. `obj.public = ...`) and one which is not allowed to be updated via
    # the class level `Model.update(id, { ... })`.
    def self.protected_fields
      []
    end

    protected def metaclass
      class << self; self; end
    end

    protected def remove_accessors(keys)
      # not available in the #instance_eval below
      protected_fields = self.class.protected_fields

      metaclass.instance_eval do
        keys.each do |k|
          next if protected_fields.include?(k)
          next if @@permanent_attributes.include?(k)

          # Remove methods for the accessor's reader and writer.
          [k, :"#{k}=", :"#{k}?"].each do |method_name|
            next unless method_defined?(method_name)

            begin
              remove_method(method_name)
            rescue NameError
              # In some cases there can be a method that's detected with
              # `method_defined?`, but which cannot be removed with
              # `remove_method`, even though it's on the same class. The only
              # case so far that we've noticed this is when a class is
              # reopened for monkey patching:
              #
              #     https://github.com/stripe/stripe-ruby/issues/749
              #
              # Here we swallow that error and issue a warning so at least
              # the program doesn't crash.
              warn("WARNING: Unable to remove method `#{method_name}`; " \
                "if custom, please consider renaming to a name that doesn't " \
                "collide with an API property name.")
            end
          end
        end
      end
    end

    protected def add_accessors(keys, values)
      # not available in the #instance_eval below
      protected_fields = self.class.protected_fields

      metaclass.instance_eval do
        keys.each do |k|
          next if protected_fields.include?(k)
          next if @@permanent_attributes.include?(k)

          if k == :method
            # Object#method is a built-in Ruby method that accepts a symbol
            # and returns the corresponding Method object. Because the API may
            # also use `method` as a field name, we check the arity of *args
            # to decide whether to act as a getter or call the parent method.
            define_method(k) { |*args| args.empty? ? @values[k] : super(*args) }
          else
            define_method(k) { @values[k] }
          end

          define_method(:"#{k}=") do |v|
            if v == ""
              raise ArgumentError, "You cannot set #{k} to an empty string. " \
                "We interpret empty strings as nil in requests. " \
                "You may set (object).#{k} = nil to delete the property."
            end
            @values[k] = Util.convert_to_stripe_object(v, @opts)
            dirty_value!(@values[k])
            @unsaved_values.add(k)
          end

          if [FalseClass, TrueClass].include?(values[k].class)
            define_method(:"#{k}?") { @values[k] }
          end
        end
      end
    end

    # Disabling the cop because it's confused by the fact that the methods are
    # protected, but we do define `#respond_to_missing?` just below. Hopefully
    # this is fixed in more recent Rubocop versions.
    # rubocop:disable Style/MissingRespondToMissing
    protected def method_missing(name, *args)
      # TODO: only allow setting in updateable classes.
      if name.to_s.end_with?("=")
        attr = name.to_s[0...-1].to_sym

        # Pull out the assigned value. This is only used in the case of a
        # boolean value to add a question mark accessor (i.e. `foo?`) for
        # convenience.
        val = args.first

        # the second argument is only required when adding boolean accessors
        add_accessors([attr], attr => val)

        begin
          mth = method(name)
        rescue NameError
          raise NoMethodError,
                "Cannot set #{attr} on this object. HINT: you can't set: " \
                "#{@@permanent_attributes.to_a.join(', ')}"
        end
        return mth.call(args[0])
      elsif @values.key?(name)
        return @values[name]
      end

      begin
        super
      rescue NoMethodError => e
        # If we notice the accessed name of our set of transient values we can
        # give the user a slightly more helpful error message. If not, just
        # raise right away.
        raise unless @transient_values.include?(name)

        raise NoMethodError,
              e.message + ".  HINT: The '#{name}' attribute was set in the " \
              "past, however.  It was then wiped when refreshing the object " \
              "with the result returned by Stripe's API, probably as a " \
              "result of a save().  The attributes currently available on " \
              "this object are: #{@values.keys.join(', ')}"
      end
    end
    # rubocop:enable Style/MissingRespondToMissing

    protected def respond_to_missing?(symbol, include_private = false)
      @values && @values.key?(symbol) || super
    end

    # Re-initializes the object based on a hash of values (usually one that's
    # come back from an API call). Adds or removes value accessors as necessary
    # and updates the state of internal data.
    #
    # Protected on purpose! Please do not expose.
    #
    # ==== Options
    #
    # * +:values:+ Hash used to update accessors and values.
    # * +:opts:+ Options for StripeObject like an API key.
    # * +:partial:+ Indicates that the re-initialization should not attempt to
    #   remove accessors.
    protected def initialize_from(values, opts, partial = false)
      @opts = Util.normalize_opts(opts)

      # the `#send` is here so that we can keep this method private
      @original_values = self.class.send(:deep_copy, values)

      removed = partial ? Set.new : Set.new(@values.keys - values.keys)
      added = Set.new(values.keys - @values.keys)

      # Wipe old state before setting new.  This is useful for e.g. updating a
      # customer, where there is no persistent card parameter.  Mark those
      # values which don't persist as transient

      remove_accessors(removed)
      add_accessors(added, values)

      removed.each do |k|
        @values.delete(k)
        @transient_values.add(k)
        @unsaved_values.delete(k)
      end

      update_attributes(values, opts, dirty: false)
      values.each_key do |k|
        @transient_values.delete(k)
        @unsaved_values.delete(k)
      end

      self
    end

    protected def serialize_params_value(value, original, unsaved, force,
                                         key: nil)
      if value.nil?
        ""

      # The logic here is that essentially any object embedded in another
      # object that had a `type` is actually an API resource of a different
      # type that's been included in the response. These other resources must
      # be updated from their proper endpoints, and therefore they are not
      # included when serializing even if they've been modified.
      #
      # There are _some_ known exceptions though.
      #
      # For example, if the value is unsaved (meaning the user has set it), and
      # it looks like the API resource is persisted with an ID, then we include
      # the object so that parameters are serialized with a reference to its
      # ID.
      #
      # Another example is that on save API calls it's sometimes desirable to
      # update a customer's default source by setting a new card (or other)
      # object with `#source=` and then saving the customer. The
      # `#save_with_parent` flag to override the default behavior allows us to
      # handle these exceptions.
      #
      # We throw an error if a property was set explicitly but we can't do
      # anything with it because the integration is probably not working as the
      # user intended it to.
      elsif value.is_a?(APIResource) && !value.save_with_parent
        if !unsaved
          nil
        elsif value.respond_to?(:id) && !value.id.nil?
          value
        else
          raise ArgumentError, "Cannot save property `#{key}` containing " \
            "an API resource. It doesn't appear to be persisted and is " \
            "not marked as `save_with_parent`."
        end

      elsif value.is_a?(Array)
        update = value.map { |v| serialize_params_value(v, nil, true, force) }

        # This prevents an array that's unchanged from being resent.
        update if update != serialize_params_value(original, nil, true, force)

      # Handle a Hash for now, but in the long run we should be able to
      # eliminate all places where hashes are stored as values internally by
      # making sure any time one is set, we convert it to a StripeObject. This
      # will simplify our model by making data within an object more
      # consistent.
      #
      # For now, you can still run into a hash if someone appends one to an
      # existing array being held by a StripeObject. This could happen for
      # example by appending a new hash onto `additional_owners` for an
      # account.
      elsif value.is_a?(Hash)
        Util.convert_to_stripe_object(value, @opts).serialize_params

      elsif value.is_a?(StripeObject)
        update = value.serialize_params(force: force)

        # If the entire object was replaced and this is an additive object,
        # then we need blank each field of the old object that held a value
        # because otherwise the update to the keys of the object will be
        # additive instead of a full replacement. The new serialized values
        # will override any of these empty values.
        if original && unsaved && key && self.class.additive_object_param?(key)
          update = empty_values(original).merge(update)
        end

        update

      else
        value
      end
    end

    # Produces a deep copy of the given object including support for arrays,
    # hashes, and StripeObjects.
    private_class_method def self.deep_copy(obj)
      case obj
      when Array
        obj.map { |e| deep_copy(e) }
      when Hash
        obj.each_with_object({}) do |(k, v), copy|
          copy[k] = deep_copy(v)
          copy
        end
      when StripeObject
        obj.class.construct_from(
          deep_copy(obj.instance_variable_get(:@values)),
          obj.instance_variable_get(:@opts).select do |k, _v|
            Util::OPTS_COPYABLE.include?(k)
          end
        )
      else
        obj
      end
    end

    private def dirty_value!(value)
      case value
      when Array
        value.map { |v| dirty_value!(v) }
      when StripeObject
        value.dirty!
      end
    end

    # Returns a hash of empty values for all the values that are in the given
    # StripeObject.
    private def empty_values(obj)
      values = case obj
               when Hash         then obj
               when StripeObject then obj.instance_variable_get(:@values)
               else
                 raise ArgumentError,
                       "#empty_values got unexpected object type: " \
                       "#{obj.class.name}"
               end

      values.each_with_object({}) do |(k, _), update|
        update[k] = ""
      end
    end
  end
end
