# frozen_string_literal: true

module GraphQL
  class Schema
    class Visibility
      # This class filters the types, fields, arguments, enum values, and directives in a schema
      # based on the given `context`.
      #
      # It's like {Warden}, but has some differences:
      #
      # - It doesn't use {Schema}'s top-level caches (eg {Schema.references_to}, {Schema.possible_types}, {Schema.types})
      # - It doesn't hide Interface or Union types when all their possible types are hidden. (Instead, those types should implement `.visible?` to hide in that case.)
      # - It checks `.visible?` on root introspection types
      # - It can be used to cache profiles by name for re-use across queries
      class Profile
        # @return [Schema::Visibility::Profile]
        def self.from_context(ctx, schema)
          if ctx.respond_to?(:types) && (types = ctx.types).is_a?(self)
            types
          else
            schema.visibility.profile_for(ctx)
          end
        end

        def self.null_profile(context:, schema:)
          profile = self.new(name: "NullProfile", context: context, schema: schema)
          profile.instance_variable_set(:@cached_visible, Hash.new { |k, v| k[v] = true }.compare_by_identity)
          profile
        end

        # @return [Symbol, nil]
        attr_reader :name

        def freeze
          @cached_visible.default_proc = nil
          @cached_visible_fields.default_proc = nil
          @cached_visible_fields.each do |type, fields|
            fields.default_proc = nil
          end
          @cached_visible_arguments.default_proc = nil
          @cached_visible_arguments.each do |type, fields|
            fields.default_proc = nil
          end
          @cached_parent_fields.default_proc = nil
          @cached_parent_fields.each do |type, fields|
            fields.default_proc = nil
          end
          @cached_parent_arguments.default_proc = nil
          @cached_parent_arguments.each do |type, args|
            args.default_proc = nil
          end
          @cached_possible_types.default_proc = nil
          @cached_enum_values.default_proc = nil
          @cached_fields.default_proc = nil
          @cached_arguments.default_proc = nil
          @loadable_possible_types.default_proc = nil
          @cached_field_result.default_proc = nil
          @cached_field_result.each { |_, h| h.default_proc = nil }
          @cached_type_result.default_proc = nil
          super
        end

        def initialize(name: nil, context:, schema:, visibility:)
          @name = name
          @context = context
          @schema = schema
          @visibility = visibility
          @all_types = {}
          @all_types_loaded = false
          @unvisited_types = []
          @all_directives = nil
          @cached_visible = Hash.new { |h, member| h[member] = @schema.visible?(member, @context) }.compare_by_identity

          @cached_visible_fields = Hash.new { |h, owner|
            h[owner] = Hash.new do |h2, field|
              h2[field] = visible_field_for(owner, field)
            end.compare_by_identity
          }.compare_by_identity

          @cached_visible_arguments = Hash.new do |h, owner|
            h[owner] = Hash.new do |h2, arg|
              h2[arg] = if @cached_visible[arg] && (arg_type = arg.type.unwrap) && @cached_visible[arg_type]
                case owner
                when GraphQL::Schema::Field
                  @cached_visible_fields[owner.owner][owner]
                when Class
                  @cached_visible[owner]
                else
                  raise "Unexpected argument owner for `#{arg.path}`: #{owner.inspect}"
                end
              else
                false
              end
            end.compare_by_identity
          end.compare_by_identity

          @cached_parent_fields = Hash.new do |h, type|
            h[type] = Hash.new do |h2, field_name|
              h2[field_name] = type.get_field(field_name, @context)
            end
          end.compare_by_identity

          @cached_parent_arguments = Hash.new do |h, arg_owner|
            h[arg_owner] = Hash.new do |h2, arg_name|
              h2[arg_name] = arg_owner.get_argument(arg_name, @context)
            end
          end.compare_by_identity

          @cached_possible_types = Hash.new { |h, type| h[type] = possible_types_for(type) }.compare_by_identity

          @cached_enum_values = Hash.new do |h, enum_t|
            values = non_duplicate_items(enum_t.enum_values(@context), @cached_visible)
            if values.size == 0
              raise GraphQL::Schema::Enum::MissingValuesError.new(enum_t)
            end
            h[enum_t] = values
          end.compare_by_identity

          @cached_fields = Hash.new do |h, owner|
            h[owner] = non_duplicate_items(owner.all_field_definitions.each(&:ensure_loaded), @cached_visible_fields[owner])
          end.compare_by_identity

          @cached_arguments = Hash.new do |h, owner|
            h[owner] = non_duplicate_items(owner.all_argument_definitions, @cached_visible_arguments[owner])
          end.compare_by_identity

          @loadable_possible_types = Hash.new { |h, union_type| h[union_type] = union_type.possible_types }.compare_by_identity

          # Combined cache for field(owner, field_name) — avoids repeated kind check + parent lookup + visibility check
          @cached_field_result = Hash.new { |h, owner|
            h[owner] = Hash.new { |h2, field_name| h2[field_name] = compute_field(owner, field_name) }
          }.compare_by_identity

          # Cache for type(type_name) — avoids repeated get_type + visibility + referenced? checks
          @cached_type_result = Hash.new { |h, type_name| h[type_name] = compute_type(type_name) }
        end

        def field_on_visible_interface?(field, owner)
          ints = owner.interface_type_memberships.map(&:abstract_type)
          field_name = field.graphql_name
          filtered_ints = interfaces(owner)
          any_interface_has_field = false
          any_interface_has_visible_field = false
          ints.each do |int_t|
            if (_int_f_defn = @cached_parent_fields[int_t][field_name])
              any_interface_has_field = true

              if filtered_ints.include?(int_t) # TODO cycles, or maybe not necessary since previously checked? && @cached_visible_fields[owner][field]
                any_interface_has_visible_field = true
                break
              end
            end
          end

          if any_interface_has_field
            any_interface_has_visible_field
          else
            true
          end
        end

        def type(type_name)
          @cached_type_result[type_name]
        end

        def field(owner, field_name)
          @cached_field_result[owner][field_name]
        end

        def fields(owner)
          @cached_fields[owner]
        end

        def arguments(owner)
          @cached_arguments[owner]
        end

        def argument(owner, arg_name)
          arg = @cached_parent_arguments[owner][arg_name]
          if arg.is_a?(Array)
            visible_arg = nil
            arg.each do |arg_defn|
              if @cached_visible_arguments[owner][arg_defn]
                if visible_arg.nil?
                  visible_arg = arg_defn
                else
                  raise_duplicate_definition(visible_arg, arg_defn)
                end
              end
            end
            visible_arg
          else
            if arg && @cached_visible_arguments[owner][arg]
              arg
            else
              nil
            end
          end
        end

        def possible_types(type)
          @cached_possible_types[type]
        end

        def interfaces(obj_or_int_type)
          ints = obj_or_int_type.interface_type_memberships
            .select { |itm| @cached_visible[itm] && @cached_visible[itm.abstract_type] }
            .map!(&:abstract_type)
          ints.uniq! # Remove any duplicate interfaces implemented via other interfaces
          ints
        end

        def query_root
          ((t = @schema.query) && @cached_visible[t]) ? t : nil
        end

        def mutation_root
          ((t = @schema.mutation) && @cached_visible[t]) ? t : nil
        end

        def subscription_root
          ((t = @schema.subscription) && @cached_visible[t]) ? t : nil
        end

        def all_types
          load_all_types
          @all_types.values
        end

        def all_types_h
          load_all_types
          @all_types
        end

        def enum_values(owner)
          @cached_enum_values[owner]
        end

        def directive_exists?(dir_name)
          directives.any? { |d| d.graphql_name == dir_name }
        end

        def directives
          @all_directives ||= @visibility.all_directives.select { |dir|
            @cached_visible[dir] && @visibility.all_references[dir].any? { |ref| ref == true || (@cached_visible[ref] && referenced?(ref)) }
          }
        end

        def loadable?(t, _ctx)
          @cached_visible[t] && !referenced?(t)
        end

        def loadable_possible_types(t, _ctx)
          @loadable_possible_types[t]
        end

        def loaded_types
          @all_types.values
        end

        def reachable_type?(type_name)
          load_all_types
          !!@all_types[type_name]
        end

        def visible_enum_value?(enum_value, _ctx = nil)
          @cached_visible[enum_value]
        end

        def preload
          load_all_types
          @all_types.each do |type_name, type_defn|
            type(type_name)
            if type_defn.kind.fields?
              fields(type_defn).each do |f|
                field(type_defn, f.graphql_name)
                arguments(f).each do |arg|
                  argument(f, arg.graphql_name)
                end
              end
              @schema.introspection_system.dynamic_fields.each do |f|
                field(type_defn, f.graphql_name)
              end
            elsif type_defn.kind.input_object?
              arguments(type_defn).each do |arg|
                argument(type_defn, arg.graphql_name)
              end
            elsif type_defn.kind.enum?
              enum_values(type_defn)
            end
            # Lots more to do here
          end
          if @schema.query
            @schema.introspection_system.entry_points.each do |f|
              arguments(f).each do |arg|
                argument(f, arg.graphql_name)
              end
              field(@schema.query, f.graphql_name)
            end
          end
          @schema.introspection_system.dynamic_fields.each do |f|
            arguments(f).each do |arg|
              argument(f, arg.graphql_name)
            end
          end

        end

        private

        def compute_type(type_name)
          t = @visibility.get_type(type_name) # rubocop:disable Development/ContextIsPassedCop
          if t
            if t.is_a?(Array)
              vis_t = nil
              t.each do |t_defn|
                if @cached_visible[t_defn] && referenced?(t_defn)
                  if vis_t.nil?
                    vis_t = t_defn
                  else
                    raise_duplicate_definition(vis_t, t_defn)
                  end
                end
              end
              vis_t
            else
              if t && @cached_visible[t] && referenced?(t)
                t
              else
                nil
              end
            end
          end
        end

        def compute_field(owner, field_name)
          f = if owner.kind.fields? && (field = @cached_parent_fields[owner][field_name])
            field
          elsif owner == query_root && (entry_point_field = @schema.introspection_system.entry_point(name: field_name))
            entry_point_field
          elsif (dynamic_field = @schema.introspection_system.dynamic_field(name: field_name))
            dynamic_field
          else
            nil
          end
          if f.is_a?(Array)
            visible_f = nil
            f.each do |f_defn|
              if @cached_visible_fields[owner][f_defn]
                if visible_f.nil?
                  visible_f = f_defn
                else
                  raise_duplicate_definition(visible_f, f_defn)
                end
              end
            end
            visible_f&.ensure_loaded
          elsif f && @cached_visible_fields[owner][f.ensure_loaded]
            f
          else
            nil
          end
        end

        def non_duplicate_items(definitions, visibility_cache)
          non_dups = []
          names = Set.new
          definitions.each do |defn|
            if visibility_cache[defn]
              if !names.add?(defn.graphql_name)
                dup_defn = non_dups.find { |d| d.graphql_name == defn.graphql_name }
                raise_duplicate_definition(dup_defn, defn)
              end
              non_dups << defn
            end
          end
          non_dups
        end

        def raise_duplicate_definition(first_defn, second_defn)
          raise DuplicateNamesError.new(duplicated_name: first_defn.path, duplicated_definition_1: first_defn.inspect, duplicated_definition_2: second_defn.inspect)
        end

        def load_all_types
          return if @all_types_loaded
          @all_types_loaded = true
          visit = Visibility::Visit.new(@schema) do |member|
            if member.is_a?(Module) && member.respond_to?(:kind)
              if @cached_visible[member] && referenced?(member)
                type_name = member.graphql_name
                if (prev_t = @all_types[type_name]) && !prev_t.equal?(member)
                  raise_duplicate_definition(member, prev_t)
                end
                @all_types[type_name] = member
                true
              else
                false
              end
            else
              @cached_visible[member]
            end
          end
          visit.visit_each
          @all_types.delete_if { |type_name, type_defn| !referenced?(type_defn) }
          nil
        end

        def referenced?(type_defn)
          @visibility.all_references[type_defn].any? do |ref|
            case ref
            when GraphQL::Schema::Argument
              @cached_visible_arguments[ref.owner][ref]
            when GraphQL::Schema::Field
              @cached_visible_fields[ref.owner][ref]
            when Module
              @cached_visible[ref]
            when true
              true
            end
          end
        end

        def possible_types_for(type)
          case type.kind.name
          when "INTERFACE"
            pts = []
            @visibility.all_interface_type_memberships[type].each do |impl_type, type_memberships|
              if impl_type.kind.object? && referenced?(impl_type) && @cached_visible[impl_type]
                if type_memberships.any? { |itm| @cached_visible[itm] }
                  pts << impl_type
                end
              end
            end
            pts
          when "UNION"
            pts = []
            type.type_memberships.each { |tm|
              if @cached_visible[tm] &&
                  (ot = tm.object_type) &&
                  @cached_visible[ot] &&
                  referenced?(ot)
                pts << ot
              end
            }
            pts
          when "OBJECT"
            if @cached_visible[type]
              [type]
            else
              EmptyObjects::EMPTY_ARRAY
            end
          else
            GraphQL::EmptyObjects::EMPTY_ARRAY
          end
        end

        def visible_field_for(owner, field)
          @cached_visible[field] &&
            (ret_type = field.type.unwrap) &&
            @cached_visible[ret_type] &&
            (owner == field.owner || (!owner.kind.object?) || field_on_visible_interface?(field, owner))
        end
      end
    end
  end
end
