# typed: strict
# frozen_string_literal: true

class DynamicMixinCompiler
  extend T::Sig
  include Tapioca::Reflection

  sig { returns(T::Array[Module]) }
  attr_reader :dynamic_extends, :dynamic_includes

  sig { returns(T::Array[Symbol]) }
  attr_reader :class_attribute_readers, :class_attribute_writers, :class_attribute_predicates

  sig { returns(T::Array[Symbol]) }
  attr_reader :instance_attribute_readers, :instance_attribute_writers, :instance_attribute_predicates

  sig { params(constant: Module).void }
  def initialize(constant)
    @constant = constant
    mixins_from_modules = {}.compare_by_identity
    class_attribute_readers = T.let([], T::Array[Symbol])
    class_attribute_writers = T.let([], T::Array[Symbol])
    class_attribute_predicates = T.let([], T::Array[Symbol])

    instance_attribute_readers = T.let([], T::Array[Symbol])
    instance_attribute_writers = T.let([], T::Array[Symbol])
    instance_attribute_predicates = T.let([], T::Array[Symbol])

    Class.new do
      # Override the `self.include` method
      define_singleton_method(:include) do |mod|
        # Take a snapshot of the list of singleton class ancestors
        # before the actual include
        before = singleton_class.ancestors
        # Call the actual `include` method with the supplied module
        super(mod).tap do
          # Take a snapshot of the list of singleton class ancestors
          # after the actual include
          after = singleton_class.ancestors
          # The difference is the modules that are added to the list
          # of ancestors of the singleton class. Those are all the
          # modules that were `extend`ed due to the `include` call.
          #
          # We record those modules on our lookup table keyed by
          # the included module with the values being all the modules
          # that that module pulls into the singleton class.
          #
          # We need to reverse the order, since the extend order should
          # be the inverse of the ancestor order. That is, earlier
          # extended modules would be later in the ancestor chain.
          mixins_from_modules[mod] = (after - before).reverse!
        end
      rescue Exception # rubocop:disable Lint/RescueException
        # this is a best effort, bail if we can't perform this
      end

      define_singleton_method(:class_attribute) do |*attrs, **kwargs|
        class_attribute_readers.concat(attrs)
        class_attribute_writers.concat(attrs)

        instance_predicate = kwargs.fetch(:instance_predicate, true)
        instance_accessor = kwargs.fetch(:instance_accessor, true)
        instance_reader = kwargs.fetch(:instance_reader, instance_accessor)
        instance_writer = kwargs.fetch(:instance_writer, instance_accessor)

        if instance_reader
          instance_attribute_readers.concat(attrs)
        end

        if instance_writer
          instance_attribute_writers.concat(attrs)
        end

        if instance_predicate
          class_attribute_predicates.concat(attrs)

          if instance_reader
            instance_attribute_predicates.concat(attrs)
          end
        end

        super(*attrs, **kwargs) if defined?(super)
      end

      # rubocop:disable Style/MissingRespondToMissing
      T::Sig::WithoutRuntime.sig { params(symbol: Symbol, args: T.untyped).returns(T.untyped) }
      def method_missing(symbol, *args)
        # We need this here so that we can handle any random instance
        # method calls on the fake including class that may be done by
        # the included module during the `self.included` hook.
      end

      class << self
        extend T::Sig

        T::Sig::WithoutRuntime.sig { params(symbol: Symbol, args: T.untyped).returns(T.untyped) }
        def method_missing(symbol, *args)
          # Similarly, we need this here so that we can handle any
          # random class method calls on the fake including class
          # that may be done by the included module during the
          # `self.included` hook.
        end
      end
      # rubocop:enable Style/MissingRespondToMissing
    end.include(constant)

    # The value that corresponds to the original included constant
    # is the list of all dynamically extended modules because of that
    # constant. We grab that value by deleting the key for the original
    # constant.
    @dynamic_extends = T.let(mixins_from_modules.delete(constant) || [], T::Array[Module])

    # Since we deleted the original constant from the list of keys, all
    # the keys that remain are the ones that are dynamically included modules
    # during the include of the original constant.
    @dynamic_includes = T.let(mixins_from_modules.keys, T::Array[Module])

    @class_attribute_readers = T.let(class_attribute_readers, T::Array[Symbol])
    @class_attribute_writers = T.let(class_attribute_writers, T::Array[Symbol])
    @class_attribute_predicates = T.let(class_attribute_predicates, T::Array[Symbol])

    @instance_attribute_readers = T.let(instance_attribute_readers, T::Array[Symbol])
    @instance_attribute_writers = T.let(instance_attribute_writers, T::Array[Symbol])
    @instance_attribute_predicates = T.let(instance_attribute_predicates, T::Array[Symbol])
  end

  sig { returns(T::Boolean) }
  def empty_attributes?
    @class_attribute_readers.empty? && @class_attribute_writers.empty?
  end

  sig { params(tree: RBI::Tree).void }
  def compile_class_attributes(tree)
    return if empty_attributes?

    # Create a synthetic module to hold the generated class methods
    tree << RBI::Module.new("GeneratedClassMethods") do |mod|
      class_attribute_readers.each do |attribute|
        mod << RBI::Method.new(attribute.to_s)
      end

      class_attribute_writers.each do |attribute|
        mod << RBI::Method.new("#{attribute}=") do |method|
          method << RBI::Param.new("value")
        end
      end

      class_attribute_predicates.each do |attribute|
        mod << RBI::Method.new("#{attribute}?")
      end
    end

    # Create a synthetic module to hold the generated instance methods
    tree << RBI::Module.new("GeneratedInstanceMethods") do |mod|
      instance_attribute_readers.each do |attribute|
        mod << RBI::Method.new(attribute.to_s)
      end

      instance_attribute_writers.each do |attribute|
        mod << RBI::Method.new("#{attribute}=") do |method|
          method << RBI::Param.new("value")
        end
      end

      instance_attribute_predicates.each do |attribute|
        mod << RBI::Method.new("#{attribute}?")
      end
    end

    # Add a mixes_in_class_methods and include for the generated modules
    tree << RBI::MixesInClassMethods.new("GeneratedClassMethods")
    tree << RBI::Include.new("GeneratedInstanceMethods")
  end

  sig { params(tree: RBI::Tree).returns([T::Array[Module], T::Array[Module]]) }
  def compile_mixes_in_class_methods(tree)
    includes = dynamic_includes.select { |mod| (name = name_of(mod)) && !name.start_with?("T::") }
    includes.each do |mod|
      qname = qualified_name_of(mod)
      tree << RBI::Include.new(T.must(qname))
    end

    # If we can generate multiple mixes_in_class_methods, then we want to use all dynamic extends that are not the
    # constant itself
    mixed_in_class_methods = dynamic_extends.select { |mod| mod != @constant }
    return [[], []] if mixed_in_class_methods.empty?

    mixed_in_class_methods.each do |mod|
      qualified_name = qualified_name_of(mod)
      next if qualified_name.nil? || qualified_name.empty?
      tree << RBI::MixesInClassMethods.new(qualified_name)
    end

    [mixed_in_class_methods, includes]
  rescue
    [[], []] # silence errors
  end
end