# typed: strict
# frozen_string_literal: true

module RubyLsp
  class RubyDocument < Document
    extend T::Sig
    extend T::Generic

    ParseResultType = type_member { { fixed: Prism::ParseResult } }

    class SorbetLevel < T::Enum
      enums do
        None = new("none")
        Ignore = new("ignore")
        False = new("false")
        True = new("true")
        Strict = new("strict")
      end
    end

    class << self
      extend T::Sig

      sig do
        params(
          node: Prism::Node,
          char_position: Integer,
          code_units_cache: T.any(
            T.proc.params(arg0: Integer).returns(Integer),
            Prism::CodeUnitsCache,
          ),
          node_types: T::Array[T.class_of(Prism::Node)],
        ).returns(NodeContext)
      end
      def locate(node, char_position, code_units_cache:, node_types: [])
        queue = T.let(node.child_nodes.compact, T::Array[T.nilable(Prism::Node)])
        closest = node
        parent = T.let(nil, T.nilable(Prism::Node))
        nesting_nodes = T.let(
          [],
          T::Array[T.any(
            Prism::ClassNode,
            Prism::ModuleNode,
            Prism::SingletonClassNode,
            Prism::DefNode,
            Prism::BlockNode,
            Prism::LambdaNode,
            Prism::ProgramNode,
          )],
        )

        nesting_nodes << node if node.is_a?(Prism::ProgramNode)
        call_node = T.let(nil, T.nilable(Prism::CallNode))

        until queue.empty?
          candidate = queue.shift

          # Skip nil child nodes
          next if candidate.nil?

          # Add the next child_nodes to the queue to be processed. The order here is important! We want to move in the
          # same order as the visiting mechanism, which means searching the child nodes before moving on to the next
          # sibling
          T.unsafe(queue).unshift(*candidate.child_nodes)

          # Skip if the current node doesn't cover the desired position
          loc = candidate.location
          loc_start_offset = loc.cached_start_code_units_offset(code_units_cache)
          loc_end_offset = loc.cached_end_code_units_offset(code_units_cache)
          next unless (loc_start_offset...loc_end_offset).cover?(char_position)

          # If the node's start character is already past the position, then we should've found the closest node
          # already
          break if char_position < loc_start_offset

          # If the candidate starts after the end of the previous nesting level, then we've exited that nesting level
          # and need to pop the stack
          previous_level = nesting_nodes.last
          if previous_level &&
              (loc_start_offset > previous_level.location.cached_end_code_units_offset(code_units_cache))
            nesting_nodes.pop
          end

          # Keep track of the nesting where we found the target. This is used to determine the fully qualified name of
          # the target when it is a constant
          case candidate
          when Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
            Prism::LambdaNode
            nesting_nodes << candidate
          end

          if candidate.is_a?(Prism::CallNode)
            arg_loc = candidate.arguments&.location
            blk_loc = candidate.block&.location
            if (arg_loc && (arg_loc.cached_start_code_units_offset(code_units_cache)...
                            arg_loc.cached_end_code_units_offset(code_units_cache)).cover?(char_position)) ||
                (blk_loc && (blk_loc.cached_start_code_units_offset(code_units_cache)...
                            blk_loc.cached_end_code_units_offset(code_units_cache)).cover?(char_position))
              call_node = candidate
            end
          end

          # If there are node types to filter by, and the current node is not one of those types, then skip it
          next if node_types.any? && node_types.none? { |type| candidate.class == type }

          # If the current node is narrower than or equal to the previous closest node, then it is more precise
          closest_loc = closest.location
          closest_node_start_offset = closest_loc.cached_start_code_units_offset(code_units_cache)
          closest_node_end_offset = closest_loc.cached_end_code_units_offset(code_units_cache)
          if loc_end_offset - loc_start_offset <= closest_node_end_offset - closest_node_start_offset
            parent = closest
            closest = candidate
          end
        end

        # When targeting the constant part of a class/module definition, we do not want the nesting to be duplicated.
        # That is, when targeting Bar in the following example:
        #
        # ```ruby
        #   class Foo::Bar; end
        # ```
        # The correct target is `Foo::Bar` with an empty nesting. `Foo::Bar` should not appear in the nesting stack,
        # even though the class/module node does indeed enclose the target, because it would lead to incorrect behavior
        if closest.is_a?(Prism::ConstantReadNode) || closest.is_a?(Prism::ConstantPathNode)
          last_level = nesting_nodes.last

          if (last_level.is_a?(Prism::ModuleNode) || last_level.is_a?(Prism::ClassNode)) &&
              last_level.constant_path == closest
            nesting_nodes.pop
          end
        end

        NodeContext.new(closest, parent, nesting_nodes, call_node)
      end
    end

    sig do
      returns(T.any(
        T.proc.params(arg0: Integer).returns(Integer),
        Prism::CodeUnitsCache,
      ))
    end
    attr_reader :code_units_cache

    sig { params(source: String, version: Integer, uri: URI::Generic, encoding: Encoding).void }
    def initialize(source:, version:, uri:, encoding: Encoding::UTF_8)
      super
      @code_units_cache = T.let(@parse_result.code_units_cache(@encoding), T.any(
        T.proc.params(arg0: Integer).returns(Integer),
        Prism::CodeUnitsCache,
      ))
    end

    sig { override.returns(T::Boolean) }
    def parse!
      return false unless @needs_parsing

      @needs_parsing = false
      @parse_result = Prism.parse(@source)
      @code_units_cache = @parse_result.code_units_cache(@encoding)
      true
    end

    sig { override.returns(T::Boolean) }
    def syntax_error?
      @parse_result.failure?
    end

    sig { override.returns(LanguageId) }
    def language_id
      LanguageId::Ruby
    end

    sig { returns(SorbetLevel) }
    def sorbet_level
      sigil = parse_result.magic_comments.find do |comment|
        comment.key == "typed"
      end&.value

      case sigil
      when "ignore"
        SorbetLevel::Ignore
      when "false"
        SorbetLevel::False
      when "true"
        SorbetLevel::True
      when "strict", "strong"
        SorbetLevel::Strict
      else
        SorbetLevel::None
      end
    end

    sig do
      params(
        range: T::Hash[Symbol, T.untyped],
        node_types: T::Array[T.class_of(Prism::Node)],
      ).returns(T.nilable(Prism::Node))
    end
    def locate_first_within_range(range, node_types: [])
      scanner = create_scanner
      start_position = scanner.find_char_position(range[:start])
      end_position = scanner.find_char_position(range[:end])
      desired_range = (start_position...end_position)
      queue = T.let(@parse_result.value.child_nodes.compact, T::Array[T.nilable(Prism::Node)])

      until queue.empty?
        candidate = queue.shift

        # Skip nil child nodes
        next if candidate.nil?

        # Add the next child_nodes to the queue to be processed. The order here is important! We want to move in the
        # same order as the visiting mechanism, which means searching the child nodes before moving on to the next
        # sibling
        T.unsafe(queue).unshift(*candidate.child_nodes)

        # Skip if the current node doesn't cover the desired position
        loc = candidate.location

        if desired_range.cover?(loc.start_offset...loc.end_offset) &&
            (node_types.empty? || node_types.any? { |type| candidate.class == type })
          return candidate
        end
      end
    end

    sig do
      params(
        position: T::Hash[Symbol, T.untyped],
        node_types: T::Array[T.class_of(Prism::Node)],
      ).returns(NodeContext)
    end
    def locate_node(position, node_types: [])
      RubyDocument.locate(
        @parse_result.value,
        create_scanner.find_char_position(position),
        code_units_cache: @code_units_cache,
        node_types: node_types,
      )
    end
  end
end