# typed: strict
# frozen_string_literal: true

module Tapioca
  # This class is responsible for storing and looking up information related to generic types.
  #
  # The class stores 2 different kinds of data, in two separate lookup tables:
  #   1. a lookup of generic type instances by name: `@generic_instances`
  #   2. a lookup of type variable serializer by constant and type variable
  #      instance: `@type_variables`
  #
  # By storing the above data, we can cheaply query each constant against this registry
  # to see if it declares any generic type variables. This becomes a simple lookup in the
  # `@type_variables` hash table with the given constant.
  #
  # If there is no entry, then we can cheaply know that we can skip generic type
  # information generation for this type.
  #
  # On the other hand, if we get a result, then the result will be a hash of type
  # variable to type variable serializers. This allows us to associate type variables
  # to the constant names that represent them, easily.
  module GenericTypeRegistry
    @generic_instances = T.let(
      {},
      T::Hash[String, Module]
    )

    @type_variables = T.let(
      {},
      T::Hash[Integer, T::Hash[Integer, String]]
    )

    class << self
      extend T::Sig

      # This method is responsible for building the name of the instantiated concrete type
      # and cloning the given constant so that we can return a type that is the same
      # as the current type but is a different instance and has a different name method.
      #
      # We cache those cloned instances by their name in `@generic_instances`, so that
      # we don't keep instantiating a new type every single time it is referenced.
      # For example, `[Foo[Integer], Foo[Integer], Foo[Integer], Foo[String]]` will only
      # result in 2 clones (1 for `Foo[Integer]` and another for `Foo[String]`) and
      # 2 hash lookups (for the other two `Foo[Integer]`s).
      #
      # This method returns the created or cached clone of the constant.
      sig { params(constant: T.untyped, types: T.untyped).returns(Module) }
      def register_type(constant, types)
        # Build the name of the instantiated generic type,
        # something like `"Foo[X, Y, Z]"`
        type_list = types.map { |type| T::Utils.coerce(type).name }.join(", ")
        name = "#{name_of(constant)}[#{type_list}]"

        # Create a clone of the constant with an overridden `name`
        # method that returns the name we constructed above.
        #
        # Also, we try to memoize the clone based on the name, so that
        # we don't have to keep recreating clones all the time.
        @generic_instances[name] ||= constant.clone.tap do |clone|
          clone.define_singleton_method(:name) { name }
        end
      end

      sig do
        params(
          constant: T.untyped,
          type_member: T::Types::TypeVariable,
          fixed: T.untyped,
          lower: T.untyped,
          upper: T.untyped
        ).void
      end
      def register_type_member(constant, type_member, fixed, lower, upper)
        register_type_variable(constant, :type_member, type_member, fixed, lower, upper)
      end

      sig do
        params(
          constant: T.untyped,
          type_template: T::Types::TypeVariable,
          fixed: T.untyped,
          lower: T.untyped,
          upper: T.untyped
        ).void
      end
      def register_type_template(constant, type_template, fixed, lower, upper)
        register_type_variable(constant, :type_template, type_template, fixed, lower, upper)
      end

      sig { params(constant: Module).returns(T.nilable(T::Hash[Integer, String])) }
      def lookup_type_variables(constant)
        @type_variables[object_id_of(constant)]
      end

      private

      # This method is called from intercepted calls to `type_member` and `type_template`.
      # We get passed all the arguments to those methods, as well as the `T::Types::TypeVariable`
      # instance generated by the Sorbet defined `type_member`/`type_template` call on `T::Generic`.
      #
      # This method creates a `String` with that data and stores it in the
      # `@type_variables` lookup table, keyed by the `constant` and `type_variable`.
      #
      # Finally, the original `type_variable` is returned from this method, so that the caller
      # can return it from the original methods as well.
      sig do
        params(
          constant: T.untyped,
          type_variable_type: T.enum([:type_member, :type_template]),
          type_variable: T::Types::TypeVariable,
          fixed: T.untyped,
          lower: T.untyped,
          upper: T.untyped
        ).void
      end
      # rubocop:disable Metrics/ParameterLists
      def register_type_variable(constant, type_variable_type, type_variable, fixed, lower, upper)
        # rubocop:enable Metrics/ParameterLists
        type_variables = lookup_or_initialize_type_variables(constant)

        type_variables[object_id_of(type_variable)] = serialize_type_variable(
          type_variable_type,
          type_variable.variance,
          fixed,
          lower,
          upper
        )
      end

      sig { params(constant: Module).returns(T::Hash[Integer, String]) }
      def lookup_or_initialize_type_variables(constant)
        @type_variables[object_id_of(constant)] ||= {}
      end

      sig do
        params(
          type_variable_type: Symbol,
          variance: Symbol,
          fixed: T.untyped,
          lower: T.untyped,
          upper: T.untyped
        ).returns(String)
      end
      def serialize_type_variable(type_variable_type, variance, fixed, lower, upper)
        parts = []
        parts << ":#{variance}" unless variance == :invariant
        parts << "fixed: #{fixed}" if fixed
        parts << "lower: #{lower}" unless lower == T.untyped
        parts << "upper: #{upper}" unless upper == BasicObject

        parameters = parts.join(", ")

        serialized = T.let(type_variable_type.to_s, String)
        serialized += "(#{parameters})" unless parameters.empty?

        serialized
      end

      sig { params(constant: Module).returns(T.nilable(String)) }
      def name_of(constant)
        Module.instance_method(:name).bind(constant).call
      end

      sig { params(object: BasicObject).returns(Integer) }
      def object_id_of(object)
        Object.instance_method(:object_id).bind(object).call
      end
    end
  end
end