# typed: strict
# frozen_string_literal: true

begin
  require "identity_cache"
rescue LoadError
  # means IdentityCache is not installed,
  # so let's not even define the compiler.
  return
end

require "tapioca/dsl/helpers/active_record_column_type_helper"

module Tapioca
  module Dsl
    module Compilers
      # `Tapioca::Dsl::Compilers::IdentityCache` generates RBI files for Active Record models
      #  that use `include IdentityCache`.
      # [`IdentityCache`](https://github.com/Shopify/identity_cache) is a blob level caching solution
      # to plug into Active Record.
      #
      # For example, with the following Active Record class:
      #
      # ~~~rb
      # # post.rb
      # class Post < ApplicationRecord
      #    include IdentityCache
      #
      #    cache_index :blog_id
      #    cache_index :title, unique: true
      #    cache_index :title, :review_date, unique: true
      #
      # end
      # ~~~
      #
      # this compiler will produce the RBI file `post.rbi` with the following content:
      #
      # ~~~rbi
      # # post.rbi
      # # typed: true
      # class Post
      #   sig { params(blog_id: T.untyped, includes: T.untyped).returns(T::Array[::Post])
      #   def fetch_by_blog_id(blog_id, includes: nil); end
      #
      #   sig { params(blog_ids: T.untyped, includes: T.untyped).returns(T::Array[::Post])
      #   def fetch_multi_by_blog_id(index_values, includes: nil); end
      #
      #   sig { params(title: T.untyped, includes: T.untyped).returns(::Post) }
      #   def fetch_by_title!(title, includes: nil); end
      #
      #   sig { params(title: T.untyped, includes: T.untyped).returns(T.nilable(::Post)) }
      #   def fetch_by_title(title, includes: nil); end
      #
      #   sig { params(index_values: T.untyped, includes: T.untyped).returns(T::Array[::Post]) }
      #   def fetch_multi_by_title(index_values, includes: nil); end
      #
      #   sig { params(title: T.untyped, review_date: T.untyped, includes: T.untyped).returns(T::Array[::Post]) }
      #   def fetch_by_title_and_review_date!(title, review_date, includes: nil); end
      #
      #   sig { params(title: T.untyped, review_date: T.untyped, includes: T.untyped).returns(T::Array[::Post]) }
      #   def fetch_by_title_and_review_date(title, review_date, includes: nil); end
      # end
      # ~~~
      class IdentityCache < Compiler
        extend T::Sig

        COLLECTION_TYPE = T.let(
          ->(type) { "T::Array[::#{type}]" },
          T.proc.params(type: T.any(Module, String)).returns(String),
        )

        ConstantType = type_member { { fixed: T.class_of(::ActiveRecord::Base) } }

        sig { override.void }
        def decorate
          caches = constant.send(:all_cached_associations)
          cache_indexes = constant.send(:cache_indexes)
          return if caches.empty? && cache_indexes.empty?

          root.create_path(constant) do |model|
            cache_manys = constant.send(:cached_has_manys)
            cache_ones = constant.send(:cached_has_ones)
            cache_belongs = constant.send(:cached_belongs_tos)

            cache_indexes.each do |field|
              create_fetch_by_methods(field, model)
            end

            cache_manys.values.each do |field|
              create_fetch_field_methods(field, model, returns_collection: true)
            end

            cache_ones.values.each do |field|
              create_fetch_field_methods(field, model, returns_collection: false)
            end

            cache_belongs.values.each do |field|
              create_fetch_field_methods(field, model, returns_collection: false)
            end
          end
        end

        class << self
          extend T::Sig

          sig { override.returns(T::Enumerable[Module]) }
          def gather_constants
            descendants_of(::ActiveRecord::Base).select do |klass|
              klass < ::IdentityCache::WithoutPrimaryIndex
            end
          end
        end

        private

        sig do
          params(
            field: T.untyped,
            returns_collection: T::Boolean,
          ).returns(String)
        end
        def type_for_field(field, returns_collection:)
          cache_type = field.reflection.compute_class(field.reflection.class_name)
          if returns_collection
            COLLECTION_TYPE.call(cache_type)
          else
            as_nilable_type(T.must(qualified_name_of(cache_type)))
          end
        rescue ArgumentError
          "T.untyped"
        end

        sig do
          params(
            field: T.untyped,
            klass: RBI::Scope,
            returns_collection: T::Boolean,
          ).void
        end
        def create_fetch_field_methods(field, klass, returns_collection:)
          name = field.cached_accessor_name.to_s
          type = type_for_field(field, returns_collection: returns_collection)
          klass.create_method(name, return_type: type)

          if field.respond_to?(:cached_ids_name)
            klass.create_method(field.cached_ids_name, return_type: "T::Array[T.untyped]")
          elsif field.respond_to?(:cached_id_name)
            klass.create_method(field.cached_id_name, return_type: "T.untyped")
          end
        end

        sig do
          params(
            field: T.untyped,
            klass: RBI::Scope,
          ).void
        end
        def create_fetch_by_methods(field, klass)
          is_cache_index = field.instance_variable_defined?(:@attribute_proc)

          # Both `cache_index` and `cache_attribute` generate aliased methods
          create_aliased_fetch_by_methods(field, klass)

          # If the method used was `cache_index` a few extra methods are created
          create_index_fetch_by_methods(field, klass) if is_cache_index
        end

        sig do
          params(
            field: T.untyped,
            klass: RBI::Scope,
          ).void
        end
        def create_index_fetch_by_methods(field, klass)
          field_length = field.key_fields.length
          fields_name = field.key_fields.join("_and_")
          name = "fetch_by_#{fields_name}"
          parameters = field.key_fields.map do |arg|
            create_param(arg.to_s, type: "T.untyped")
          end
          parameters << create_kw_opt_param("includes", default: "nil", type: "T.untyped")

          if field.unique
            type = T.must(qualified_name_of(constant))

            klass.create_method(
              "#{name}!",
              class_method: true,
              parameters: parameters,
              return_type: type,
            )

            klass.create_method(
              name,
              class_method: true,
              parameters: parameters,
              return_type: as_nilable_type(type),
            )
          else
            klass.create_method(
              name,
              class_method: true,
              parameters: parameters,
              return_type: COLLECTION_TYPE.call(constant),
            )
          end

          if field_length == 1
            klass.create_method(
              "fetch_multi_by_#{fields_name}",
              class_method: true,
              parameters: [
                create_param("index_values", type: "T::Enumerable[T.untyped]"),
                create_kw_opt_param("includes", default: "nil", type: "T.untyped"),
              ],
              return_type: COLLECTION_TYPE.call(constant),
            )
          end
        end

        sig do
          params(
            field: T.untyped,
            klass: RBI::Scope,
          ).void
        end
        def create_aliased_fetch_by_methods(field, klass)
          type, _ = Helpers::ActiveRecordColumnTypeHelper.new(constant).type_for(field.alias_name.to_s)
          multi_type = type.delete_prefix("T.nilable(").delete_suffix(")").delete_prefix("::")
          length = field.key_fields.length
          suffix = field.send(:fetch_method_suffix)

          parameters = field.key_fields.map do |arg|
            create_param(arg.to_s, type: "T.untyped")
          end

          klass.create_method(
            "fetch_#{suffix}",
            class_method: true,
            parameters: parameters,
            return_type: type,
          )

          if length == 1
            klass.create_method(
              "fetch_multi_#{suffix}",
              class_method: true,
              parameters: [create_param("keys", type: "T::Enumerable[T.untyped]")],
              return_type: COLLECTION_TYPE.call(multi_type),
            )
          end
        end
      end
    end
  end
end