# typed: strict
# frozen_string_literal: true

require "ruby_lsp/requests/support/source_uri"

module RubyLsp
  module Listeners
    class DocumentLink
      extend T::Sig
      include Requests::Support::Common

      GEM_TO_VERSION_MAP = T.let(
        [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].map! do |s|
          [s.name, s.version.to_s]
        end.to_h.freeze,
        T::Hash[String, String],
      )

      class << self
        extend T::Sig

        sig { returns(T::Hash[String, T::Hash[String, T::Hash[String, String]]]) }
        def gem_paths
          @gem_paths ||= T.let(
            begin
              lookup = {}

              Gem::Specification.stubs.each do |stub|
                spec = stub.to_spec
                lookup[spec.name] = {}
                lookup[spec.name][spec.version.to_s] = {}

                Dir.glob("**/*.rb", base: "#{spec.full_gem_path.delete_prefix("//?/")}/").each do |path|
                  lookup[spec.name][spec.version.to_s][path] = "#{spec.full_gem_path}/#{path}"
                end
              end

              Gem::Specification.default_stubs.each do |stub|
                spec = stub.to_spec
                lookup[spec.name] = {}
                lookup[spec.name][spec.version.to_s] = {}
                prefix_matchers = Regexp.union(spec.require_paths.map do |rp|
                                                 Regexp.new("^#{rp}/")
                                               end)
                prefix_matcher = Regexp.union(prefix_matchers, //)

                spec.files.each do |file|
                  path = file.sub(prefix_matcher, "")
                  lookup[spec.name][spec.version.to_s][path] = "#{RbConfig::CONFIG["rubylibdir"]}/#{path}"
                end
              end

              lookup
            end,
            T.nilable(T::Hash[String, T::Hash[String, T::Hash[String, String]]]),
          )
        end
      end

      sig do
        params(
          response_builder: ResponseBuilders::CollectionResponseBuilder[Interface::DocumentLink],
          uri: URI::Generic,
          comments: T::Array[Prism::Comment],
          dispatcher: Prism::Dispatcher,
        ).void
      end
      def initialize(response_builder, uri, comments, dispatcher)
        # Match the version based on the version in the RBI file name. Notice that the `@` symbol is sanitized to `%40`
        # in the URI
        @response_builder = response_builder
        path = uri.to_standardized_path
        version_match = path ? /(?<=%40)[\d.]+(?=\.rbi$)/.match(path) : nil
        @gem_version = T.let(version_match && version_match[0], T.nilable(String))
        @lines_to_comments = T.let(
          comments.to_h do |comment|
            [comment.location.end_line, comment]
          end,
          T::Hash[Integer, Prism::Comment],
        )

        dispatcher.register(
          self,
          :on_def_node_enter,
          :on_class_node_enter,
          :on_module_node_enter,
          :on_constant_write_node_enter,
          :on_constant_path_write_node_enter,
        )
      end

      sig { params(node: Prism::DefNode).void }
      def on_def_node_enter(node)
        extract_document_link(node)
      end

      sig { params(node: Prism::ClassNode).void }
      def on_class_node_enter(node)
        extract_document_link(node)
      end

      sig { params(node: Prism::ModuleNode).void }
      def on_module_node_enter(node)
        extract_document_link(node)
      end

      sig { params(node: Prism::ConstantWriteNode).void }
      def on_constant_write_node_enter(node)
        extract_document_link(node)
      end

      sig { params(node: Prism::ConstantPathWriteNode).void }
      def on_constant_path_write_node_enter(node)
        extract_document_link(node)
      end

      private

      sig { params(node: Prism::Node).void }
      def extract_document_link(node)
        comment = @lines_to_comments[node.location.start_line - 1]
        return unless comment

        match = comment.location.slice.match(%r{source://.*#\d+$})
        return unless match

        uri = T.cast(URI(T.must(match[0])), URI::Source)
        gem_version = resolve_version(uri)
        return if gem_version.nil?

        file_path = self.class.gem_paths.dig(uri.gem_name, gem_version, CGI.unescape(uri.path))
        return if file_path.nil?

        @response_builder << Interface::DocumentLink.new(
          range: range_from_location(comment.location),
          target: "file://#{file_path}##{uri.line_number}",
          tooltip: "Jump to #{file_path}##{uri.line_number}",
        )
      end

      # Try to figure out the gem version for a source:// link. The order of precedence is:
      # 1. The version in the URI
      # 2. The version in the RBI file name
      # 3. The version from the gemspec
      sig { params(uri: URI::Source).returns(T.nilable(String)) }
      def resolve_version(uri)
        version = uri.gem_version
        return version unless version.nil? || version.empty?

        return @gem_version unless @gem_version.nil? || @gem_version.empty?

        GEM_TO_VERSION_MAP[uri.gem_name]
      end
    end
  end
end