# frozen_string_literal: true
require "set"
module GraphQL
  class Subscriptions
    # Serialization helpers for passing subscription data around.
    # @api private
    module Serialize
      GLOBALID_KEY = "__gid__"
      SYMBOL_KEY = "__sym__"
      SYMBOL_KEYS_KEY = "__sym_keys__"
      TIMESTAMP_KEY = "__timestamp__"
      TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%N%z" # eg '2020-01-01 23:59:59.123456789+05:00'
      OPEN_STRUCT_KEY = "__ostruct__"

      module_function

      # @param str [String] A serialized object from {.dump}
      # @return [Object] An object equivalent to the one passed to {.dump}
      def load(str)
        parsed_obj = JSON.parse(str)
        load_value(parsed_obj)
      end

      # @param obj [Object] Some subscription-related data to dump
      # @return [String] The stringified object
      def dump(obj)
        JSON.generate(dump_value(obj), quirks_mode: true)
      end

      # This is for turning objects into subscription scopes.
      # It's a one-way transformation, can't reload this :'(
      # @param obj [Object]
      # @return [String]
      def dump_recursive(obj)
        case
        when obj.is_a?(Array)
          obj.map { |i| dump_recursive(i) }.join(':')
        when obj.is_a?(Hash)
          obj.map { |k, v| "#{dump_recursive(k)}:#{dump_recursive(v)}" }.join(":")
        when obj.is_a?(GraphQL::Schema::InputObject)
          dump_recursive(obj.to_h)
        when obj.respond_to?(:to_gid_param)
          obj.to_gid_param
        when obj.respond_to?(:to_param)
          obj.to_param
        else
          obj.to_s
        end
      end

      class << self
        private

        # @param value [Object] A parsed JSON object
        # @return [Object] An object that load Global::Identification recursive
        def load_value(value)
          if value.is_a?(Array)
            is_gids = (v1 = value[0]).is_a?(Hash) && v1.size == 1 && v1[GLOBALID_KEY]
            if is_gids
              # Assume it's an array of global IDs
              ids = value.map { |v| v[GLOBALID_KEY] }
              GlobalID::Locator.locate_many(ids)
            else
              value.map { |item| load_value(item) }
            end
          elsif value.is_a?(Hash)
            if value.size == 1
              case value.keys.first # there's only 1 key
              when GLOBALID_KEY
                GlobalID::Locator.locate(value[GLOBALID_KEY])
              when SYMBOL_KEY
                value[SYMBOL_KEY].to_sym
              when TIMESTAMP_KEY
                timestamp_class_name, *timestamp_args = value[TIMESTAMP_KEY]
                timestamp_class = Object.const_get(timestamp_class_name)
                if defined?(ActiveSupport::TimeWithZone) && timestamp_class <= ActiveSupport::TimeWithZone
                  zone_name, timestamp_s = timestamp_args
                  zone = ActiveSupport::TimeZone[zone_name]
                  raise "Zone #{zone_name} not found, unable to deserialize" unless zone
                  zone.strptime(timestamp_s, TIMESTAMP_FORMAT)
                else
                  timestamp_s = timestamp_args.first
                  timestamp_class.strptime(timestamp_s, TIMESTAMP_FORMAT)
                end
              when OPEN_STRUCT_KEY
                ostruct_values = load_value(value[OPEN_STRUCT_KEY])
                OpenStruct.new(ostruct_values)
              else
                key = value.keys.first
                { key => load_value(value[key]) }
              end
            else
              loaded_h = {}
              sym_keys = value.fetch(SYMBOL_KEYS_KEY, [])
              value.each do |k, v|
                if k == SYMBOL_KEYS_KEY
                  next
                end
                if sym_keys.include?(k)
                  k = k.to_sym
                end
                loaded_h[k] = load_value(v)
              end
              loaded_h
            end
          else
            value
          end
        end

        # @param obj [Object] Some subscription-related data to dump
        # @return [Object] The object that converted Global::Identification
        def dump_value(obj)
          if obj.is_a?(Array)
            obj.map{|item| dump_value(item)}
          elsif obj.is_a?(Hash)
            symbol_keys = nil
            dumped_h = {}
            obj.each do |k, v|
              dumped_h[k.to_s] = dump_value(v)
              if k.is_a?(Symbol)
                symbol_keys ||= Set.new
                symbol_keys << k.to_s
              end
            end
            if symbol_keys
              dumped_h[SYMBOL_KEYS_KEY] = symbol_keys.to_a
            end
            dumped_h
          elsif obj.is_a?(Symbol)
            { SYMBOL_KEY => obj.to_s }
          elsif obj.respond_to?(:to_gid_param)
            {GLOBALID_KEY => obj.to_gid_param}
          elsif defined?(ActiveSupport::TimeWithZone) && obj.is_a?(ActiveSupport::TimeWithZone) && obj.class.name != Time.name
            # This handles a case where Rails prior to 7 would
            # make the class ActiveSupport::TimeWithZone return "Time" for
            # its name. In Rails 7, it will now return "ActiveSupport::TimeWithZone",
            # which happens to be incompatible with expectations we have
            # with what a Time class supports ( notably, strptime in `load_value` ).
            #
            # This now passes along the name of the zone, such that a future deserialization
            # of this string will use the correct time zone from the ActiveSupport TimeZone
            # list to produce the time.
            #
            { TIMESTAMP_KEY => [obj.class.name, obj.time_zone.name, obj.strftime(TIMESTAMP_FORMAT)] }
          elsif obj.is_a?(Date) || obj.is_a?(Time)
            # DateTime extends Date; for TimeWithZone, call `.utc` first.
            { TIMESTAMP_KEY => [obj.class.name, obj.strftime(TIMESTAMP_FORMAT)] }
          elsif obj.is_a?(OpenStruct)
            { OPEN_STRUCT_KEY => dump_value(obj.to_h) }
          else
            obj
          end
        end
      end
    end
  end
end
