src/derails/passive_resistance/messages/metadata.rb
# frozen_string_literal: true

require "time"
require "passive_resistance/json"
require_relative "serializer_with_fallback"

module PassiveResistance
  module Messages # :nodoc:
    module Metadata # :nodoc:
      singleton_class.attr_accessor :use_message_serializer_for_metadata

      ENVELOPE_SERIALIZERS = [
        *SerializerWithFallback::SERIALIZERS.values,
        PassiveResistance::JSON,
        ::JSON,
        Marshal,
      ]

      TIMESTAMP_SERIALIZERS = [
        SerializerWithFallback::SERIALIZERS.fetch(:message_pack),
        SerializerWithFallback::SERIALIZERS.fetch(:message_pack_allow_marshal),
      ]

      PassiveResistance.on_load(:message_pack) do
        ENVELOPE_SERIALIZERS << PassiveResistance::MessagePack
        TIMESTAMP_SERIALIZERS << PassiveResistance::MessagePack
      end

      private
        def serialize_with_metadata(data, **metadata)
          has_metadata = metadata.any? { |k, v| v }

          if has_metadata && !use_message_serializer_for_metadata?
            data_string = serialize_to_json_safe_string(data)
            envelope = wrap_in_metadata_legacy_envelope({ "message" => data_string }, **metadata)
            serialize_to_json(envelope)
          else
            data = wrap_in_metadata_envelope({ "data" => data }, **metadata) if has_metadata
            serialize(data)
          end
        end

        def deserialize_with_metadata(message, **expected_metadata)
          if dual_serialized_metadata_envelope_json?(message)
            envelope = deserialize_from_json(message)
            extracted = extract_from_metadata_envelope(envelope, **expected_metadata)
            deserialize_from_json_safe_string(extracted["message"])
          else
            deserialized = deserialize(message)
            if metadata_envelope?(deserialized)
              extract_from_metadata_envelope(deserialized, **expected_metadata)["data"]
            elsif expected_metadata.none? { |k, v| v }
              deserialized
            else
              throw :invalid_message_content, "missing metadata"
            end
          end
        end

        def use_message_serializer_for_metadata?
          Metadata.use_message_serializer_for_metadata && Metadata::ENVELOPE_SERIALIZERS.include?(serializer)
        end

        def wrap_in_metadata_envelope(hash, expires_at: nil, expires_in: nil, purpose: nil)
          expiry = pick_expiry(expires_at, expires_in)
          hash["exp"] = expiry if expiry
          hash["pur"] = purpose.to_s if purpose
          { "_rails" => hash }
        end

        def wrap_in_metadata_legacy_envelope(hash, expires_at: nil, expires_in: nil, purpose: nil)
          expiry = pick_expiry(expires_at, expires_in)
          hash["exp"] = expiry
          hash["pur"] = purpose
          { "_rails" => hash }
        end

        def extract_from_metadata_envelope(envelope, purpose: nil)
          hash = envelope["_rails"]

          if hash["exp"] && Time.now.utc >= parse_expiry(hash["exp"])
            throw :invalid_message_content, "expired"
          end

          if hash["pur"].to_s != purpose.to_s
            throw :invalid_message_content, "mismatched purpose"
          end

          hash
        end

        def metadata_envelope?(object)
          object.is_a?(Hash) && object.key?("_rails")
        end

        def dual_serialized_metadata_envelope_json?(string)
          string.start_with?('{"_rails":{"message":')
        end

        def pick_expiry(expires_at, expires_in)
          expiry = if expires_at
            expires_at.utc
          elsif expires_in
            Time.now.utc.advance(seconds: expires_in)
          end

          unless Metadata::TIMESTAMP_SERIALIZERS.include?(serializer)
            expiry = expiry&.iso8601(3)
          end

          expiry
        end

        def parse_expiry(expires_at)
          if !expires_at.is_a?(String)
            expires_at
          elsif PassiveResistance.use_standard_json_time_format
            Time.iso8601(expires_at)
          else
            Time.parse(expires_at)
          end
        end

        def serialize_to_json(data)
          PassiveResistance::JSON.encode(data)
        end

        def deserialize_from_json(serialized)
          PassiveResistance::JSON.decode(serialized)
        rescue ::JSON::ParserError => error
          # Throw :invalid_message_format instead of :invalid_message_serialization
          # because here a parse error is due to a bad message rather than an
          # incompatible `self.serializer`.
          throw :invalid_message_format, error
        end

        def serialize_to_json_safe_string(data)
          encode(serialize(data), url_safe: false)
        end

        def deserialize_from_json_safe_string(string)
          deserialize(decode(string, url_safe: false))
        end
    end
  end
end
