# typed: strict
# frozen_string_literal: true

module RubyLsp
  module Requests
    module Support
      class RuboCopDiagnostic
        RUBOCOP_TO_LSP_SEVERITY = {
          info: Constant::DiagnosticSeverity::HINT,
          refactor: Constant::DiagnosticSeverity::INFORMATION,
          convention: Constant::DiagnosticSeverity::INFORMATION,
          warning: Constant::DiagnosticSeverity::WARNING,
          error: Constant::DiagnosticSeverity::ERROR,
          fatal: Constant::DiagnosticSeverity::ERROR,
        }.freeze #: Hash[Symbol, Integer]

        # Cops where adding a `rubocop:disable` inline comment would itself resolve the offense,
        # causing Lint/RedundantCopDisableDirective to flag the disable as unnecessary.
        SELF_RESOLVING_DISABLE_COPS = Set.new([
          "Layout/EmptyComment",
        ]).freeze #: Set[String]

        ENHANCED_DOC_URL = begin
          gem("rubocop", ">= 1.64.0")
          true
        rescue LoadError
          false
        end #: bool

        # TODO: avoid passing document once we have alternative ways to get at
        # encoding and file source
        #: (RubyDocument document, ::RuboCop::Cop::Offense offense, URI::Generic uri) -> void
        def initialize(document, offense, uri)
          @document = document
          @offense = offense
          @uri = uri
        end

        #: -> Array[Interface::CodeAction]
        def to_lsp_code_actions
          code_actions = []

          code_actions << autocorrect_action if correctable?
          code_actions << disable_line_action unless SELF_RESOLVING_DISABLE_COPS.include?(@offense.cop_name)

          code_actions
        end

        #: (::RuboCop::Config config) -> Interface::Diagnostic
        def to_lsp_diagnostic(config)
          # highlighted_area contains the begin and end position of the first line
          # This ensures that multiline offenses don't clutter the editor
          highlighted = @offense.highlighted_area
          Interface::Diagnostic.new(
            message: message,
            source: "RuboCop",
            code: @offense.cop_name,
            code_description: code_description(config),
            severity: severity,
            range: Interface::Range.new(
              start: Interface::Position.new(
                line: @offense.line - 1,
                character: highlighted.begin_pos,
              ),
              end: Interface::Position.new(
                line: @offense.line - 1,
                character: highlighted.end_pos,
              ),
            ),
            data: {
              correctable: correctable?,
              code_actions: to_lsp_code_actions,
            },
          )
        end

        private

        #: -> String
        def message
          message  = @offense.message
          message += "\n\nThis offense is not auto-correctable.\n" unless correctable?
          message
        end

        #: -> Integer?
        def severity
          RUBOCOP_TO_LSP_SEVERITY[@offense.severity.name]
        end

        #: (::RuboCop::Config config) -> Interface::CodeDescription?
        def code_description(config)
          cop = RuboCopRunner.find_cop_by_name(@offense.cop_name)
          return unless cop

          doc_url = if ENHANCED_DOC_URL
            cop.documentation_url(config)
          else
            cop.documentation_url
          end
          Interface::CodeDescription.new(href: doc_url) if doc_url
        end

        #: -> Interface::CodeAction
        def autocorrect_action
          Interface::CodeAction.new(
            title: "Autocorrect #{@offense.cop_name}",
            kind: Constant::CodeActionKind::QUICK_FIX,
            edit: Interface::WorkspaceEdit.new(
              document_changes: [
                Interface::TextDocumentEdit.new(
                  text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
                    uri: @uri.to_s,
                    version: nil,
                  ),
                  edits: correctable? ? offense_replacements : [],
                ),
              ],
            ),
            is_preferred: true,
          )
        end

        #: -> Array[Interface::TextEdit]
        def offense_replacements
          @offense.corrector.as_replacements.map do |range, replacement|
            Interface::TextEdit.new(
              range: Interface::Range.new(
                start: Interface::Position.new(line: range.line - 1, character: range.column),
                end: Interface::Position.new(line: range.last_line - 1, character: range.last_column),
              ),
              new_text: replacement,
            )
          end
        end

        #: -> Interface::CodeAction
        def disable_line_action
          Interface::CodeAction.new(
            title: "Disable #{@offense.cop_name} for this line",
            kind: Constant::CodeActionKind::QUICK_FIX,
            edit: Interface::WorkspaceEdit.new(
              document_changes: [
                Interface::TextDocumentEdit.new(
                  text_document: Interface::OptionalVersionedTextDocumentIdentifier.new(
                    uri: @uri.to_s,
                    version: nil,
                  ),
                  edits: line_disable_comment,
                ),
              ],
            ),
          )
        end

        #: -> Array[Interface::TextEdit]
        def line_disable_comment
          new_text = if @offense.source_line.include?(" # rubocop:disable ")
            ",#{@offense.cop_name}"
          else
            " # rubocop:disable #{@offense.cop_name}"
          end

          eol = Interface::Position.new(
            line: @offense.line - 1,
            character: length_of_line(@offense.source_line),
          )

          # TODO: fails for multiline strings - may be preferable to use block
          # comments to disable some offenses
          inline_comment = Interface::TextEdit.new(
            range: Interface::Range.new(start: eol, end: eol),
            new_text: new_text,
          )

          [inline_comment]
        end

        #: (String line) -> Integer
        def length_of_line(line)
          if @document.encoding == Encoding::UTF_16LE
            line_length = 0
            line.codepoints.each do |codepoint|
              line_length += 1
              if codepoint > RubyLsp::Document::Scanner::SURROGATE_PAIR_START
                line_length += 1
              end
            end
            line_length
          else
            line.length
          end
        end

        # When `RuboCop::LSP.enable` is called, contextual autocorrect will not offer itself
        # as `correctable?` to prevent annoying changes while typing. Instead check if
        # a corrector is present. If it is, then that means some code transformation can be applied.
        #: -> bool
        def correctable?
          !@offense.corrector.nil?
        end
      end
    end
  end
end
