# typed: strict
# frozen_string_literal: true

module RubyLsp
  module Requests
    module Support
      class RuboCopDiagnostic
        extend T::Sig

        RUBOCOP_TO_LSP_SEVERITY = T.let(
          {
            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,
          T::Hash[Symbol, Integer],
        )

        ENHANCED_DOC_URL = T.let(
          begin
            gem("rubocop", ">= 1.64.0")
            true
          rescue LoadError
            false
          end,
          T::Boolean,
        )

        # TODO: avoid passing document once we have alternative ways to get at
        # encoding and file source
        sig { params(document: Document, offense: RuboCop::Cop::Offense, uri: URI::Generic).void }
        def initialize(document, offense, uri)
          @document = document
          @offense = offense
          @uri = uri
        end

        sig { returns(T::Array[Interface::CodeAction]) }
        def to_lsp_code_actions
          code_actions = []

          code_actions << autocorrect_action if correctable?
          code_actions << disable_line_action

          code_actions
        end

        sig { params(config: RuboCop::Config).returns(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

        sig { returns(String) }
        def message
          message  = @offense.message
          message += "\n\nThis offense is not auto-correctable.\n" unless correctable?
          message
        end

        sig { returns(T.nilable(Integer)) }
        def severity
          RUBOCOP_TO_LSP_SEVERITY[@offense.severity.name]
        end

        sig { params(config: RuboCop::Config).returns(T.nilable(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

        sig { returns(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

        sig { returns(T::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

        sig { returns(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

        sig { returns(T::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

        sig { params(line: String).returns(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.
        sig { returns(T::Boolean) }
        def correctable?
          !@offense.corrector.nil?
        end
      end
    end
  end
end