--- @brief
--- The `vim.lsp.buf_…` functions perform operations for LSP clients attached to the current buffer.

local api = vim.api
local nvim_on = require('vim._core.util').nvim_on
local lsp = vim.lsp
local validate = vim.validate
local util = require('vim.lsp.util')
local npcall = vim.npcall
local Range = require('vim.treesitter._range')

local M = {}

--- @param params? table
--- @return fun(client: vim.lsp.Client): lsp.TextDocumentPositionParams
local function client_positional_params(params)
  local win = api.nvim_get_current_win()
  return function(client)
    local ret = util.make_position_params(win, client.offset_encoding)
    if params then
      ret = vim.tbl_extend('force', ret, params)
    end
    return ret
  end
end

local hover_ns = api.nvim_create_namespace('nvim.lsp.hover_range')
local rename_ns = api.nvim_create_namespace('nvim.lsp.rename_range')

--- Returns false if the LSP response is stale and should be discarded.
--- @param ctx lsp.HandlerContext
--- @return boolean
local function ctx_is_valid(ctx)
  local bufnr = ctx.bufnr
  if
    not bufnr
    or not api.nvim_buf_is_valid(bufnr)
    or api.nvim_get_current_buf() ~= bufnr
    or vim.lsp.util.buf_versions[bufnr] ~= ctx.version
  then
    return false
  end
  ---@type lsp.Position?
  local p = ctx.params and ctx.params.position
  if not p then
    return true
  end

  local c = lsp.get_client_by_id(ctx.client_id)
  local enc = c and c.offset_encoding
  if not enc then
    return false
  end

  local cur_pos = vim.pos.cursor(0)
  local pos = vim.pos.lsp(bufnr, p, enc)
  return cur_pos == pos
end

--- @class vim.lsp.buf.hover.Opts : vim.lsp.util.open_floating_preview.Opts
--- @field silent? boolean

--- Displays hover information about the symbol under the cursor in a floating
--- window. The window will be dismissed on cursor move.
--- Calling the function twice will jump into the floating window
--- (thus by default, "KK" will open the hover window and focus it).
--- In the floating window, all commands and mappings are available as usual,
--- except that "q" dismisses the window.
--- You can scroll the contents the same as you would any other buffer.
---
--- Note: to disable hover highlights, add the following to your config:
---
--- ```lua
--- vim.api.nvim_create_autocmd('ColorScheme', {
---   callback = function()
---     vim.api.nvim_set_hl(0, 'LspReferenceTarget', {})
---   end,
--- })
--- ```
--- @param config? vim.lsp.buf.hover.Opts
function M.hover(config)
  validate('config', config, 'table', true)

  config = config or {}
  config.focus_id = 'textDocument/hover'

  lsp.buf_request_all(0, 'textDocument/hover', client_positional_params(), function(results, ctx)
    local bufnr = ctx.bufnr
    if not bufnr or not ctx_is_valid(ctx) then
      return -- Ignore result if context changed. Can happen for slow LS.
    end

    -- Filter errors from results
    local results1 = {} --- @type table<integer,lsp.Hover>
    local nresults = 0
    local empty_response = false

    for client_id, resp in pairs(results) do
      local err, result = resp.err, resp.result
      if err then
        lsp.log.error(err.code, err.message)
      elseif result and result.contents then
        -- Make sure the response is not empty
        -- Five response shapes:
        -- - MarkupContent: { kind="markdown", value="doc" }
        -- - MarkedString-string: "doc"
        -- - MarkedString-pair: { language="c", value="doc" }
        -- - MarkedString[]-string: { "doc1", ... }
        -- - MarkedString[]-pair: { { language="c", value="doc1" }, ... }
        local valid = false
        if type(result.contents) == 'table' then
          local value_len = #(
            vim.tbl_get(result.contents, 'value') -- MarkupContent or MarkedString-pair
            or vim.tbl_get(result.contents, 1, 'value') -- MarkedString[]-pair
            or result.contents[1] -- MarkedString[]-string
            or ''
          )
          valid = value_len > 0
        elseif type(result.contents) == 'string' then
          valid = #result.contents > 0
        end

        if valid then
          results1[client_id] = result
          nresults = nresults + 1
        else
          empty_response = true
        end
      end
    end

    if nresults == 0 then
      if config.silent ~= true then
        if empty_response then
          vim.notify('Empty hover response', vim.log.levels.INFO)
        else
          vim.notify('No information available', vim.log.levels.INFO)
        end
      end
      return
    end

    local contents = {} --- @type string[]
    local MarkupKind = lsp.protocol.MarkupKind
    local format = MarkupKind.Markdown

    for client_id, result in pairs(results1) do
      local client = assert(lsp.get_client_by_id(client_id))
      if nresults > 1 then
        -- Show client name if there are multiple clients
        contents[#contents + 1] = string.format('# %s', client.name)
      end

      if type(result.contents) == 'table' and result.contents.kind == MarkupKind.PlainText then
        if nresults == 1 then
          -- Only one client: use PlainText format
          format = MarkupKind.PlainText
          contents = vim.split(result.contents.value or '', '\n', { trimempty = true })
        else
          -- Multiple clients: surround plaintext with ``` to get correct formatting
          contents[#contents + 1] = '```'
          vim.list_extend(
            contents,
            vim.split(result.contents.value or '', '\n', { trimempty = true })
          )
          contents[#contents + 1] = '```'
        end
      else
        vim.list_extend(contents, util.convert_input_to_markdown_lines(result.contents))
      end
      if result.range then
        local range = vim.range.lsp(bufnr, result.range, client.offset_encoding)

        vim.hl.range(
          bufnr,
          hover_ns,
          'LspReferenceTarget',
          { range.start_row, range.start_col },
          { range.end_row, range.end_col },
          { priority = vim.hl.priorities.user }
        )
      end
      contents[#contents + 1] = '---'
    end

    -- Remove last linebreak ('---') if contents is not empty
    if #contents > 0 then
      contents[#contents] = nil
    end

    local _, winid = lsp.util.open_floating_preview(contents, format, config)

    nvim_on('WinClosed', nil, { pattern = tostring(winid), once = true }, function()
      api.nvim_buf_clear_namespace(bufnr, hover_ns, 0, -1)
      return true
    end)
  end)
end

local function request_with_opts(name, params, opts)
  local req_handler --- @type function?
  if opts then
    req_handler = function(err, result, ctx, config)
      local client = assert(lsp.get_client_by_id(ctx.client_id))
      local handler = client.handlers[name] or lsp.handlers[name]
      handler(err, result, ctx, vim.tbl_extend('force', config or {}, opts))
    end
  end
  lsp.buf_request(0, name, params, req_handler)
end

---@param method vim.lsp.protocol.Method.ClientToServer.Request
---@param context? lsp.ReferenceContext
---@param opts? vim.lsp.ListOpts
local function get_locations(method, context, opts)
  opts = opts or {}
  ---@diagnostic disable-next-line: undefined-field
  if opts.reuse_win then
    vim.deprecate(
      'vim.lsp.buf.<method>({ reuse_win = true })',
      "vim.lsp.buf.<method>() and the value of 'switchbuf' of preference",
      '0.13'
    )
  end

  local pos = opts.pos or vim.pos.cursor(0)
  local buf = pos.buf

  local clients = lsp.get_clients({ method = method, bufnr = buf })
  if not next(clients) then
    vim.notify(lsp._unsupported_method(method), vim.log.levels.WARN)
    return
  end

  local from = vim.fn.getpos('.')
  from[1] = buf
  local tagname = vim.fn.expand('<cword>')

  lsp.buf_request_all(buf, method, function(client)
    ---@type lsp.TextDocumentPositionParams
    local params = {
      textDocument = util.make_text_document_params(buf),
      position = pos:to_lsp(client.offset_encoding),
    }
    if method == 'textDocument/references' then
      ---@cast params lsp.ReferenceParams
      params.context = context or { includeDeclaration = true }
    end
    return params
  end, function(results)
    ---@type vim.quickfix.entry[]
    local all_items = {}

    for client_id, res in pairs(results) do
      local client = assert(lsp.get_client_by_id(client_id))
      local locations = {}
      if res then
        locations = vim.islist(res.result) and res.result or { res.result }
      end
      local items = util.locations_to_items(locations, client.offset_encoding)
      vim.list_extend(all_items, items)
    end

    local name = string.gsub(method:match('textDocument/(.*)'), '(%u)', ' %1'):lower()
    if vim.tbl_isempty(all_items) then
      vim.notify(('No %s found'):format(name), vim.log.levels.INFO)
      return
    end

    ---@type vim.fn.setqflist.what
    local what = {
      title = name:gsub('^%l', string.upper),
      items = all_items,
      context = { bufnr = buf, method = method },
    }
    if opts.on_list then
      validate('opts.on_list', opts.on_list, 'function')
      opts.on_list(what)
    else
      if opts.loclist then
        vim.fn.setloclist(0, {}, ' ', what)
      else
        vim.fn.setqflist({}, ' ', what)
      end

      if
        #all_items == 1
        and method ~= 'textDocument/implementation'
        and method ~= 'textDocument/references'
      then
        local tagstack = { { tagname = tagname, from = from } }
        vim.fn.settagstack(vim.fn.bufwinid(buf), { items = tagstack }, 't')
        if opts.loclist then
          vim.cmd('lfirst')
        else
          vim.cmd('cfirst')
        end
      else
        if opts.loclist then
          vim.cmd('botright lopen')
        else
          vim.cmd('botright copen')
        end
      end
    end
  end)
end

--- @class vim.lsp.ListOpts
---
--- list-handler replacing the default handler.
--- Called for any non-empty result.
--- The default handler populates the quickfix (or location) list with the result.
--- If there is a single result (and the method is not `implementation` or `references`),
--- it pushes a tag onto the tagstack and jumps to the result.
--- For example, when `loclist == false` (the default), the handler is equivalent to:
--- ```lua
--- local function on_list(what)
---   vim.fn.setqflist({}, ' ', what)
---   if
---     #what.items == 1
---     and what.context.method ~= 'textDocument/implementation'
---     and what.context.method ~= 'textDocument/references'
---   then
---     local tagstack = { { tagname = tagname, from = from } }
---     vim.fn.settagstack(vim.fn.win_getid(win), { items = tagstack }, 't')
---     vim.cmd('cfirst')
---   else
---     vim.cmd('botright copen')
---   end
--- end
---
--- vim.lsp.buf.definition({ on_list = on_list })
--- vim.lsp.buf.references(nil, { on_list = on_list })
--- ```
--- The list can be transformed before it is shown. For example, to remove
--- duplicate locations returned by multiple clients:
--- ```lua
--- local function on_list(what)
---   vim.list.unique(what.items, function(item)
---     return ('%s\0%d\0%d\0%d\0%d'):format(
---       item.filename or '',
---       item.lnum or 0,
---       item.col or 0,
---       item.end_lnum or 0,
---       item.end_col or 0
---     )
---   end)
---   vim.fn.setqflist({}, ' ', what)
---   vim.cmd('botright copen')
--- end
---
--- vim.lsp.buf.definition({ on_list = on_list })
--- vim.lsp.buf.references(nil, { on_list = on_list })
--- ```
--- See |setqflist-what| for the structure of the `what` parameter.
--- @field on_list? fun(what: vim.fn.setqflist.what)
---
--- Whether to use the |location-list| or the |quickfix| list in the default handler.
--- ```lua
--- vim.lsp.buf.definition({ loclist = true })
--- vim.lsp.buf.references(nil, { loclist = false })
--- ```
--- @field loclist? boolean
---
--- Position on a buffer to request.
--- (default: cursor position)
--- @field pos? vim.Pos

--- Jumps to the declaration of the symbol under the cursor.
--- @note Many servers do not implement this method. Generally, see |vim.lsp.buf.definition()| instead.
--- @param opts? vim.lsp.ListOpts
function M.declaration(opts)
  validate('opts', opts, 'table', true)
  get_locations('textDocument/declaration', nil, opts)
end

--- Jumps to the definition of the symbol under the cursor.
--- @param opts? vim.lsp.ListOpts
function M.definition(opts)
  validate('opts', opts, 'table', true)
  get_locations('textDocument/definition', nil, opts)
end

--- Jumps to the definition of the type of the symbol under the cursor.
--- @param opts? vim.lsp.ListOpts
function M.type_definition(opts)
  validate('opts', opts, 'table', true)
  get_locations('textDocument/typeDefinition', nil, opts)
end

--- Lists all the implementations for the symbol under the cursor in the
--- quickfix window.
--- @param opts? vim.lsp.ListOpts
function M.implementation(opts)
  validate('opts', opts, 'table', true)
  get_locations('textDocument/implementation', nil, opts)
end

--- @param results table<integer,{err: lsp.ResponseError?, result: lsp.SignatureHelp?}>
local function process_signature_help_results(results)
  local signatures = {} --- @type [vim.lsp.Client,lsp.SignatureInformation][]
  local active_signature = 1

  -- Pre-process results
  for client_id, r in pairs(results) do
    local err = r.err
    local client = assert(lsp.get_client_by_id(client_id))
    if err then
      vim.notify(
        client.name .. ': ' .. tostring(err.code) .. ': ' .. err.message,
        vim.log.levels.ERROR
      )
      api.nvim_command('redraw')
    else
      local result = r.result
      if result and result.signatures then
        for i, sig in ipairs(result.signatures) do
          sig.activeParameter = sig.activeParameter or result.activeParameter
          local idx = #signatures + 1
          if (result.activeSignature or 0) + 1 == i then
            active_signature = idx
          end
          signatures[idx] = { client, sig }
        end
      end
    end
  end

  return signatures, active_signature
end

local sig_help_ns = api.nvim_create_namespace('nvim.lsp.signature_help')

--- @class vim.lsp.buf.signature_help.Opts : vim.lsp.util.open_floating_preview.Opts
--- @field silent? boolean

--- Displays signature information about the symbol under the cursor in a
--- floating window. Allows cycling through signature overloads with `<C-s>`,
--- which can be remapped via `<Plug>(nvim.lsp.ctrl-s)`
---
--- Example:
---
--- ```lua
--- vim.keymap.set('n', '<C-b>', '<Plug>(nvim.lsp.ctrl-s)')
--- ```
---
--- @param config? vim.lsp.buf.signature_help.Opts
function M.signature_help(config)
  validate('config', config, 'table', true)

  local method = 'textDocument/signatureHelp'

  config = config and vim.deepcopy(config) or {}
  config.focus_id = method
  local user_title = config.title

  lsp.buf_request_all(0, method, client_positional_params(), function(results, ctx)
    if not ctx_is_valid(ctx) then
      return -- Ignore result if context changed. Can happen for slow LS.
    end

    local signatures, active_signature = process_signature_help_results(results)

    if not next(signatures) then
      if config.silent ~= true then
        vim.notify('No signature help available', vim.log.levels.INFO)
      end
      return
    end

    local ft = vim.bo[ctx.bufnr].filetype
    local total = #signatures
    local can_cycle = total > 1 and config.focusable ~= false
    local idx = active_signature - 1

    --- @param update_win? integer
    local function show_signature(update_win)
      idx = (idx % total) + 1
      local client, result = signatures[idx][1], signatures[idx][2]
      --- @type string[]?
      local triggers =
        vim.tbl_get(client.server_capabilities, 'signatureHelpProvider', 'triggerCharacters')
      local lines, hl =
        util.convert_signature_help_to_markdown_lines({ signatures = { result } }, ft, triggers)
      if not lines then
        return
      end

      -- Show title only if there are multiple clients or multiple signatures.
      if total > 1 then
        local sfx = total > 1
            and string.format(' (%d/%d)%s', idx, total, can_cycle and ' (<C-s> to cycle)' or '')
          or ''
        config.title = user_title or string.format('Signature Help: %s%s', client.name, sfx)
        -- If no border is set, render title inside the window.
        if not (config.border or vim.o.winborder ~= '') then
          table.insert(lines, 1, '# ' .. config.title)
          if hl then
            hl[1] = hl[1] + 1
            hl[3] = hl[3] + 1
          end
        end
      end

      config._update_win = update_win

      local buf, win = util.open_floating_preview(lines, 'markdown', config)

      if hl then
        api.nvim_buf_clear_namespace(buf, sig_help_ns, 0, -1)
        vim.hl.range(
          buf,
          sig_help_ns,
          'LspSignatureActiveParameter',
          { hl[1], hl[2] },
          { hl[3], hl[4] }
        )
      end
      return buf, win
    end

    local fbuf, fwin = show_signature()

    if can_cycle then
      vim.keymap.set('n', '<Plug>(nvim.lsp.ctrl-s)', function()
        show_signature(fwin)
      end, {
        buf = fbuf,
        desc = 'Cycle next signature',
      })
      if vim.fn.hasmapto('<Plug>(nvim.lsp.ctrl-s)', 'n') == 0 then
        vim.keymap.set('n', '<C-s>', '<Plug>(nvim.lsp.ctrl-s)', {
          buf = fbuf,
          desc = 'Cycle next signature',
        })
      end
    end
  end)
end

---@param bufnr integer
---@param mode "v"|"V"
---@return table {start={row,col}, end={row,col}} using (1, 0) indexing
local function range_from_selection(bufnr, mode)
  -- TODO: Use `vim.fn.getregionpos()` instead.

  -- [bufnum, lnum, col, off]; both row and column 1-indexed
  local start = vim.fn.getpos('v')
  local end_ = vim.fn.getpos('.')
  local start_row = start[2]
  local start_col = start[3]
  local end_row = end_[2]
  local end_col = end_[3]

  -- A user can start visual selection at the end and move backwards
  -- Normalize the range to start < end
  if start_row == end_row and end_col < start_col then
    end_col, start_col = start_col, end_col --- @type integer, integer
  elseif end_row < start_row then
    start_row, end_row = end_row, start_row --- @type integer, integer
    start_col, end_col = end_col, start_col --- @type integer, integer
  end
  if mode == 'V' then
    start_col = 1
    local lines = api.nvim_buf_get_lines(bufnr, end_row - 1, end_row, true)
    end_col = #lines[1]
  end
  return {
    ['start'] = { start_row, start_col - 1 },
    ['end'] = { end_row, end_col - 1 },
  }
end

--- @class vim.lsp.buf.format.Opts
--- @inlinedoc
---
--- Can be used to specify FormattingOptions. Some unspecified options will be
--- automatically derived from the current Nvim options.
--- See https://microsoft.github.io/language-server-protocol/specification/#formattingOptions
--- @field formatting_options? lsp.FormattingOptions
---
--- Time in milliseconds to block for formatting requests. No effect if async=true.
--- (default: `1000`)
--- @field timeout_ms? integer
---
--- Restrict formatting to the clients attached to the given buffer.
--- (default: current buffer)
--- @field bufnr? integer
---
--- Predicate used to filter clients. Receives a client as argument and must
--- return a boolean. Clients matching the predicate are included. Example:
--- ```lua
--- -- Never request typescript-language-server for formatting
--- vim.lsp.buf.format {
---   filter = function(client) return client.name ~= "ts_ls" end
--- }
--- ```
--- @field filter? fun(client: vim.lsp.Client): boolean?
---
--- If true the method won't block.
--- Editing the buffer while formatting asynchronous can lead to unexpected
--- changes.
--- (Default: false)
--- @field async? boolean
---
--- Restrict formatting to the client with ID (client.id) matching this field.
--- @field id? integer
---
--- Restrict formatting to the client with name (client.name) matching this field.
--- @field name? string
---
--- Range to format.
--- Table must contain `start` and `end` keys with {row,col} tuples using
--- (1,0) indexing. The end column can be -1 to format through the end of the line.
--- Can also be a list of tables that contain `start` and `end` keys as described above,
--- in which case `textDocument/rangesFormatting` support is required.
--- (Default: current selection in visual mode, `nil` in other modes,
--- formatting the full buffer)
--- @field range? {start:[integer,integer],end:[integer, integer]}|{start:[integer,integer],end:[integer,integer]}[]

--- Formats a buffer using the attached (and optionally filtered) language
--- server clients.
---
--- @param opts? vim.lsp.buf.format.Opts
function M.format(opts)
  validate('opts', opts, 'table', true)

  opts = opts or {}
  local bufnr = vim._resolve_bufnr(opts.bufnr)
  local mode = api.nvim_get_mode().mode
  local range = opts.range
  -- Try to use visual selection if no range is given
  if not range and mode == 'v' or mode == 'V' then
    range = range_from_selection(bufnr, mode)
  end

  local passed_multiple_ranges = (range and #range ~= 0 and type(range[1]) == 'table')
  local method ---@type vim.lsp.protocol.Method.ClientToServer.Request
  if passed_multiple_ranges then
    method = 'textDocument/rangesFormatting'
  elseif range then
    method = 'textDocument/rangeFormatting'
  else
    method = 'textDocument/formatting'
  end

  local clients = lsp.get_clients({
    id = opts.id,
    bufnr = bufnr,
    name = opts.name,
    method = method,
  })
  if opts.filter then
    clients = vim.tbl_filter(opts.filter, clients)
  end

  if #clients == 0 then
    vim.notify('[LSP] Format request failed, no matching language servers.')
  end

  --- @param client vim.lsp.Client
  --- @param params lsp.DocumentFormattingParams
  --- @return lsp.DocumentFormattingParams|lsp.DocumentRangeFormattingParams|lsp.DocumentRangesFormattingParams
  local function set_range(client, params)
    ---  @param r {start:[integer,integer],end:[integer, integer]}
    local function to_lsp_range(r)
      if r['end'][2] == -1 then
        local end_line = api.nvim_buf_get_lines(bufnr, r['end'][1] - 1, r['end'][1], true)[1]
        return {
          start = vim.pos(bufnr, r.start[1] - 1, r.start[2]):to_lsp(client.offset_encoding),
          ['end'] = vim.pos(bufnr, r['end'][1] - 1, #end_line):to_lsp(client.offset_encoding),
        }
      end
      return util.make_given_range_params(r.start, r['end'], bufnr, client.offset_encoding).range
    end

    local ret = params --[[@as lsp.DocumentFormattingParams|lsp.DocumentRangeFormattingParams|lsp.DocumentRangesFormattingParams]]
    if passed_multiple_ranges then
      --- @cast range {start:[integer,integer],end:[integer, integer]}[]
      ret = params --[[@as lsp.DocumentRangesFormattingParams]]
      ret.ranges = vim.tbl_map(to_lsp_range, range)
    elseif range then
      --- @cast range {start:[integer,integer],end:[integer, integer]}
      ret = params --[[@as lsp.DocumentRangeFormattingParams]]
      ret.range = to_lsp_range(range)
    end
    return ret
  end

  if opts.async then
    --- @param idx? integer
    --- @param client? vim.lsp.Client
    local function do_format(idx, client)
      if not idx or not client then
        return
      end
      local params = set_range(client, util.make_formatting_params(opts.formatting_options))
      client:request(method, params, function(...)
        local handler = client.handlers[method] or lsp.handlers[method]
        handler(...)
        do_format(next(clients, idx))
      end, bufnr)
    end
    do_format(next(clients))
  else
    local timeout_ms = opts.timeout_ms or 1000
    for _, client in pairs(clients) do
      local params = set_range(client, util.make_formatting_params(opts.formatting_options))
      local result, err = client:request_sync(method, params, timeout_ms, bufnr)
      if result and result.result then
        util.apply_text_edits(result.result, bufnr, client.offset_encoding)
      elseif err then
        vim.notify(string.format('[LSP][%s] %s', client.name, err), vim.log.levels.WARN)
      end
    end
  end
end

--- @class vim.lsp.buf.rename.Opts
--- @inlinedoc
---
--- Predicate used to filter clients. Receives a client as argument and
--- must return a boolean. Clients matching the predicate are included.
--- @field filter? fun(client: vim.lsp.Client): boolean?
---
--- Restrict clients used for rename to ones where client.name matches
--- this field.
--- @field name? string
---
--- (default: current buffer)
--- @field bufnr? integer

--- Renames all references to the symbol under the cursor.
---
---@param new_name string|nil If not provided, the user will be prompted for a new
---                name using |vim.ui.input()|.
---@param opts? vim.lsp.buf.rename.Opts Additional options:
function M.rename(new_name, opts)
  validate('new_name', new_name, 'string', true)
  validate('opts', opts, 'table', true)

  opts = opts or {}
  local bufnr = vim._resolve_bufnr(opts.bufnr)
  local clients = lsp.get_clients({
    bufnr = bufnr,
    name = opts.name,
    -- Clients must at least support rename, prepareRename is optional
    method = 'textDocument/rename',
  })
  if opts.filter then
    clients = vim.tbl_filter(opts.filter, clients)
  end

  if #clients == 0 then
    vim.notify('[LSP] Rename, no matching language servers with rename capability.')
  end

  local win = api.nvim_get_current_win()

  -- Compute early to account for cursor movements after going async
  local cword = vim.fn.expand('<cword>')

  --- @param range lsp.Range
  --- @param position_encoding 'utf-8'|'utf-16'|'utf-32'
  local function get_text_at_range(range, position_encoding)
    local vim_range = vim.range.lsp(bufnr, range, position_encoding)
    return api.nvim_buf_get_text(
      bufnr,
      vim_range.start_row,
      vim_range.start_col,
      vim_range.end_row,
      vim_range.end_col,
      {}
    )[1]
  end

  --- @param idx? integer
  --- @param client? vim.lsp.Client
  local function try_use_client(idx, client)
    if not idx or not client then
      return
    end

    --- @param name string
    local function rename(name)
      local params = util.make_position_params(win, client.offset_encoding) --[[@as lsp.RenameParams]]
      params.newName = name
      local handler = client.handlers['textDocument/rename'] or lsp.handlers['textDocument/rename']
      client:request('textDocument/rename', params, function(...)
        handler(...)
        try_use_client(next(clients, idx))
      end, bufnr)
    end

    if client:supports_method('textDocument/prepareRename') then
      local params = util.make_position_params(win, client.offset_encoding)
      ---@param result? lsp.Range|{ range: lsp.Range, placeholder: string }
      client:request('textDocument/prepareRename', params, function(err, result)
        if err or result == nil then
          if next(clients, idx) then
            try_use_client(next(clients, idx))
          else
            local msg = err and ('Error on prepareRename: ' .. (err.message or ''))
              or 'Nothing to rename'
            vim.notify(msg, vim.log.levels.INFO)
          end
          return
        end

        if new_name then
          rename(new_name)
          return
        end

        local range ---@type vim.Range?
        if result.start then
          ---@cast result lsp.Range
          range = vim.range.lsp(bufnr, result, client.offset_encoding)
        elseif result.range then
          ---@cast result { range: lsp.Range, placeholder: string }
          range = vim.range.lsp(bufnr, result.range, client.offset_encoding)
        end
        if range then
          vim.hl.range(
            bufnr,
            rename_ns,
            'LspReferenceTarget',
            { range.start_row, range.start_col },
            { range.end_row, range.end_col },
            { priority = vim.hl.priorities.user }
          )
        end

        local prompt_opts = {
          prompt = 'New Name: ',
          scope = 'cursor',
        }
        if result.placeholder then
          prompt_opts.default = result.placeholder
        elseif result.start then
          prompt_opts.default = get_text_at_range(result, client.offset_encoding)
        elseif result.range then
          prompt_opts.default = get_text_at_range(result.range, client.offset_encoding)
        else
          prompt_opts.default = cword
        end
        vim.ui.input(prompt_opts, function(input)
          if input and #input ~= 0 then
            rename(input)
          end
          api.nvim_buf_clear_namespace(bufnr, rename_ns, 0, -1)
        end)
      end, bufnr)
    else
      assert(
        client:supports_method('textDocument/rename'),
        'Client must support textDocument/rename'
      )
      if new_name then
        rename(new_name)
        return
      end

      local prompt_opts = {
        prompt = 'New Name: ',
        default = cword,
        scope = 'cursor',
      }
      vim.ui.input(prompt_opts, function(input)
        if not input or #input == 0 then
          return
        end
        rename(input)
      end)
    end
  end

  try_use_client(next(clients))
end

--- Lists all the references to the symbol under the cursor in the quickfix window.
---
---@param context lsp.ReferenceContext? Context for the request
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references
---@param opts? vim.lsp.ListOpts
function M.references(context, opts)
  validate('context', context, 'table', true)
  validate('opts', opts, 'table', true)
  get_locations('textDocument/references', context, opts)
end

--- Lists all symbols in the current buffer in the |location-list|.
--- @param opts? vim.lsp.ListOpts
function M.document_symbol(opts)
  validate('opts', opts, 'table', true)
  opts = vim.tbl_deep_extend('keep', opts or {}, { loclist = true })
  local params = { textDocument = util.make_text_document_params() }
  request_with_opts('textDocument/documentSymbol', params, opts)
end

--- @param client_id integer
--- @param method vim.lsp.protocol.Method.ClientToServer.Request
--- @param params table
--- @param handler? lsp.Handler
--- @param bufnr? integer
local function request_with_id(client_id, method, params, handler, bufnr)
  local client = lsp.get_client_by_id(client_id)
  if not client then
    vim.notify(
      string.format('Client with id=%d disappeared during hierarchy request', client_id),
      vim.log.levels.WARN
    )
    return
  end
  client:request(method, params, handler, bufnr)
end

--- @param item lsp.TypeHierarchyItem|lsp.CallHierarchyItem
local function format_hierarchy_item(item)
  if not item.detail or #item.detail == 0 then
    return item.name
  end
  return string.format('%s %s', item.name, item.detail)
end

--- @alias vim.lsp.buf.HierarchyMethod
--- | 'typeHierarchy/subtypes'
--- | 'typeHierarchy/supertypes'
--- | 'callHierarchy/incomingCalls'
--- | 'callHierarchy/outgoingCalls'

--- @type table<vim.lsp.buf.HierarchyMethod, 'type' | 'call'>
local hierarchy_methods = {
  ['typeHierarchy/subtypes'] = 'type',
  ['typeHierarchy/supertypes'] = 'type',
  ['callHierarchy/incomingCalls'] = 'call',
  ['callHierarchy/outgoingCalls'] = 'call',
}

--- @param method vim.lsp.buf.HierarchyMethod
local function hierarchy(method)
  local kind = hierarchy_methods[method]

  local prepare_method = kind == 'type' and 'textDocument/prepareTypeHierarchy'
    or 'textDocument/prepareCallHierarchy'

  local bufnr = api.nvim_get_current_buf()
  local clients = lsp.get_clients({ bufnr = bufnr, method = prepare_method })
  if not next(clients) then
    vim.notify(lsp._unsupported_method(method), vim.log.levels.WARN)
    return
  end

  local win = api.nvim_get_current_win()

  lsp.buf_request_all(bufnr, prepare_method, function(client)
    return util.make_position_params(win, client.offset_encoding)
  end, function(req_results)
    local results = {} --- @type [integer, lsp.TypeHierarchyItem|lsp.CallHierarchyItem][]
    for client_id, res in pairs(req_results) do
      if res.err then
        vim.notify(res.err.message, vim.log.levels.WARN)
      elseif res.result then
        local result = res.result --- @type lsp.TypeHierarchyItem[]|lsp.CallHierarchyItem[]
        for _, item in ipairs(result) do
          results[#results + 1] = { client_id, item }
        end
      end
    end

    if #results == 0 then
      vim.notify('No item resolved', vim.log.levels.WARN)
    elseif #results == 1 then
      local client_id, item = results[1][1], results[1][2]
      request_with_id(client_id, method, { item = item }, nil, bufnr)
    else
      vim.ui.select(results, {
        prompt = string.format('Select a %s hierarchy item:', kind),
        kind = kind .. 'hierarchy',
        format_item = function(x)
          return format_hierarchy_item(x[2])
        end,
      }, function(x)
        if x then
          local client_id, item = x[1], x[2]
          request_with_id(client_id, method, { item = item }, nil, bufnr)
        end
      end)
    end
  end)
end

--- Lists all the call sites of the symbol under the cursor in the
--- |quickfix| window. If the symbol can resolve to multiple
--- items, the user can pick one in the |inputlist()|.
function M.incoming_calls()
  hierarchy('callHierarchy/incomingCalls')
end

--- Lists all the items that are called by the symbol under the
--- cursor in the |quickfix| window. If the symbol can resolve to
--- multiple items, the user can pick one in the |inputlist()|.
function M.outgoing_calls()
  hierarchy('callHierarchy/outgoingCalls')
end

--- Lists all the subtypes or supertypes of the symbol under the
--- cursor in the |quickfix| window. If the symbol can resolve to
--- multiple items, the user can pick one using |vim.ui.select()|.
---@param kind "subtypes"|"supertypes"
function M.typehierarchy(kind)
  validate('kind', kind, function(v)
    return v == 'subtypes' or v == 'supertypes'
  end)

  local method = kind == 'subtypes' and 'typeHierarchy/subtypes' or 'typeHierarchy/supertypes'
  hierarchy(method)
end

--- List workspace folders.
---
function M.list_workspace_folders()
  local workspace_folders = {}
  for _, client in pairs(lsp.get_clients({ bufnr = 0 })) do
    for _, folder in pairs(client.workspace_folders or {}) do
      table.insert(workspace_folders, folder.name)
    end
  end
  return workspace_folders
end

--- Add the folder at path to the workspace folders. If {path} is
--- not provided, the user will be prompted for a path using |input()|.
--- @param workspace_folder? string
function M.add_workspace_folder(workspace_folder)
  validate('workspace_folder', workspace_folder, 'string', true)

  workspace_folder = workspace_folder
    or npcall(vim.fn.input, 'Workspace Folder: ', vim.fn.expand('%:p:h'), 'dir')
  api.nvim_command('redraw')
  if not (workspace_folder and #workspace_folder > 0) then
    return
  end
  if vim.fn.isdirectory(workspace_folder) == 0 then
    vim.notify(workspace_folder .. ' is not a valid directory')
    return
  end
  local bufnr = api.nvim_get_current_buf()
  for _, client in pairs(lsp.get_clients({ bufnr = bufnr })) do
    client:_add_workspace_folder(workspace_folder)
  end
end

--- Remove the folder at path from the workspace folders. If
--- {path} is not provided, the user will be prompted for
--- a path using |input()|.
--- @param workspace_folder? string
function M.remove_workspace_folder(workspace_folder)
  validate('workspace_folder', workspace_folder, 'string', true)

  workspace_folder = workspace_folder
    or npcall(vim.fn.input, 'Workspace Folder: ', vim.fn.expand('%:p:h'))
  api.nvim_command('redraw')
  if not workspace_folder or #workspace_folder == 0 then
    return
  end
  local bufnr = api.nvim_get_current_buf()
  for _, client in pairs(lsp.get_clients({ bufnr = bufnr })) do
    client:_remove_workspace_folder(workspace_folder)
  end
  vim.notify(workspace_folder .. 'is not currently part of the workspace')
end

--- Lists all symbols in the current workspace in the quickfix window.
---
--- The list is filtered against {query}; if the argument is omitted from the
--- call, the user is prompted to enter a string on the command line. An empty
--- string means no filtering is done.
---
--- @param query string? optional
--- @param opts? vim.lsp.ListOpts
function M.workspace_symbol(query, opts)
  validate('query', query, 'string', true)
  validate('opts', opts, 'table', true)

  query = query or npcall(vim.fn.input, 'Query: ')
  if query == nil then
    return
  end
  local params = { query = query }
  request_with_opts('workspace/symbol', params, opts)
end

--- @class vim.lsp.WorkspaceDiagnosticsOpts
--- @inlinedoc
---
--- Only request diagnostics from the indicated client. If nil, the request is sent to all clients.
--- @field client_id? integer

--- Request workspace-wide diagnostics.
--- @param opts? vim.lsp.WorkspaceDiagnosticsOpts
--- @see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_diagnostic
function M.workspace_diagnostics(opts)
  validate('opts', opts, 'table', true)

  lsp.diagnostic._workspace_diagnostics(opts or {})
end

--- Send request to the server to resolve document highlights for the current
--- text document position. This request can be triggered by a  key mapping or
--- by events such as `CursorHold`, e.g.:
---
--- ```vim
--- autocmd CursorHold  <buffer> lua vim.lsp.buf.document_highlight()
--- autocmd CursorHoldI <buffer> lua vim.lsp.buf.document_highlight()
--- autocmd CursorMoved <buffer> lua vim.lsp.buf.clear_references()
--- ```
---
--- Note: Usage of |vim.lsp.buf.document_highlight()| requires the following highlight groups
---       to be defined or you won't be able to see the actual highlights.
---         |hl-LspReferenceText|
---         |hl-LspReferenceRead|
---         |hl-LspReferenceWrite|
function M.document_highlight()
  lsp.buf_request(0, 'textDocument/documentHighlight', client_positional_params())
end

--- Removes document highlights from current buffer.
function M.clear_references()
  util.buf_clear_references()
end

---@nodoc
---@class vim.lsp.CodeActionResultEntry
---@field err? lsp.ResponseError
---@field result? (lsp.Command|lsp.CodeAction)[]
---@field context lsp.HandlerContext

--- Corresponds to `lsp.CodeActionContext`, but all fields are optional:
--- @class vim.lsp.buf.code_action.context : lsp.CodeActionContext
--- @inlinedoc
---
--- Inferred from the current position if not provided.
--- @field diagnostics? lsp.Diagnostic[]
---
--- `CodeActionKind`s used to filter the code actions. Most servers support values like "refactor" or "quickfix".
--- @field only? lsp.CodeActionKind[]
---
--- Why code actions were requested.
--- @field triggerKind? lsp.CodeActionTriggerKind

--- @class vim.lsp.buf.code_action.Opts
--- @inlinedoc
---
--- @field context? vim.lsp.buf.code_action.context
---
--- Predicate taking a code action or command and the provider's ID.
--- If it returns false, the action is filtered out.
--- @field filter? fun(x: lsp.CodeAction|lsp.Command, client_id: integer):boolean
---
--- When set to `true`, and there is just one remaining action
--- (after filtering), the action is applied without user query.
--- @field apply? boolean
---
--- Range for which code actions should be requested.
--- If in visual mode this defaults to the active selection.
--- Table must contain `start` and `end` keys with {row,col} tuples
--- using mark-like indexing. See |api-indexing|
--- @field range? {start: integer[], end: integer[]}

--- This is not public because the main extension point is
--- vim.ui.select which can be overridden independently.
---
--- Can't call/use vim.lsp.handlers['textDocument/codeAction'] because it expects
--- `(err, CodeAction[] | Command[], ctx)`, but we want to aggregate the results
--- from multiple clients to have 1 single UI prompt for the user, yet we still
--- need to be able to link a `CodeAction|Command` to the right client for
--- `codeAction/resolve`
---@param results table<integer, vim.lsp.CodeActionResultEntry>
---@param opts? vim.lsp.buf.code_action.Opts
local function on_code_action_results(results, opts)
  ---@param a lsp.Command|lsp.CodeAction
  ---@param client_id integer
  local function action_filter(a, client_id)
    -- filter by specified action kind
    if opts and opts.context then
      if opts.context.only then
        if not a.kind then
          return false
        end
        local found = false
        for _, o in ipairs(opts.context.only) do
          -- action kinds are hierarchical with . as a separator: when requesting only 'type-annotate'
          -- this filter allows both 'type-annotate' and 'type-annotate.foo', for example
          if a.kind == o or vim.startswith(a.kind, o .. '.') then
            found = true
            break
          end
        end
        if not found then
          return false
        end
      end
      -- Only show disabled code actions when the trigger kind is "Invoked".
      if a.disabled and opts.context.triggerKind ~= lsp.protocol.CodeActionTriggerKind.Invoked then
        return false
      end
    end
    -- filter by user function
    if opts and opts.filter and not opts.filter(a, client_id) then
      return false
    end
    -- no filter removed this action
    return true
  end

  ---@type {action: lsp.Command|lsp.CodeAction, ctx: lsp.HandlerContext}[]
  local actions = {}
  for _, result in pairs(results) do
    for _, action in pairs(result.result or {}) do
      if action_filter(action, result.context.client_id) then
        table.insert(actions, { action = action, ctx = result.context })
      end
    end
  end
  if #actions == 0 then
    vim.notify('No code actions available', vim.log.levels.INFO)
    return
  end

  ---@param action lsp.Command|lsp.CodeAction
  ---@param client vim.lsp.Client
  ---@param ctx lsp.HandlerContext
  local function apply_action(action, client, ctx)
    if action.edit then
      util.apply_workspace_edit(action.edit, client.offset_encoding)
    end
    local a_cmd = action.command
    if a_cmd then
      local command = type(a_cmd) == 'table' and a_cmd or action
      --- @cast command lsp.Command
      client:exec_cmd(command, ctx)
    end
  end

  ---@param choice {action: lsp.Command|lsp.CodeAction, ctx: lsp.HandlerContext}
  local function on_user_choice(choice)
    if not choice then
      return
    end

    -- textDocument/codeAction can return either Command[] or CodeAction[]
    --
    -- CodeAction
    --  ...
    --  edit?: WorkspaceEdit    -- <- must be applied before command
    --  command?: Command
    --
    -- Command:
    --  title: string
    --  command: string
    --  arguments?: any[]

    local client = assert(lsp.get_client_by_id(choice.ctx.client_id))
    local action = choice.action
    local bufnr = assert(choice.ctx.bufnr, 'Must have buffer number')

    -- Only code actions are resolved, so if we have a command, just apply it.
    if type(action.title) == 'string' and type(action.command) == 'string' then
      apply_action(action, client, choice.ctx)
      return
    end

    if action.disabled then
      vim.notify(action.disabled.reason, vim.log.levels.ERROR)
      return
    end

    if not (action.edit and action.command) and client:supports_method('codeAction/resolve') then
      client:request('codeAction/resolve', action, function(err, resolved_action)
        if err then
          -- If resolve fails, try to apply the edit/command from the original code action.
          if action.edit or action.command then
            apply_action(action, client, choice.ctx)
          else
            vim.notify(err.code .. ': ' .. err.message, vim.log.levels.ERROR)
          end
        else
          apply_action(resolved_action, client, choice.ctx)
        end
      end, bufnr)
    else
      apply_action(action, client, choice.ctx)
    end
  end

  -- If options.apply is given, and there are just one remaining code action,
  -- apply it directly without querying the user.
  if opts and opts.apply and #actions == 1 then
    on_user_choice(actions[1])
    return
  end

  ---@param item {action: lsp.Command|lsp.CodeAction, ctx: lsp.HandlerContext}
  local function format_item(item)
    local clients = lsp.get_clients({ bufnr = item.ctx.bufnr })
    local title = item.action.title:gsub('\r\n', '\\r\\n'):gsub('\n', '\\n')

    if item.action.disabled then
      title = title .. ' (disabled)'
    end

    if #clients == 1 then
      return title
    end

    local source = assert(lsp.get_client_by_id(item.ctx.client_id)).name
    return ('%s [%s]'):format(title, source)
  end

  local select_opts = {
    prompt = 'Code actions:',
    kind = 'codeaction',
    format_item = format_item,
  }
  vim.ui.select(actions, select_opts, on_user_choice)
end

---@param diagnostic vim.Diagnostic
---@param bufnr integer
---@param lnum integer
---@param col integer
---@return boolean
local function diagnostic_contains_cursor(diagnostic, bufnr, lnum, col)
  local start = vim.pos(bufnr, diagnostic.lnum, diagnostic.col)
  local finish =
    vim.pos(bufnr, diagnostic.end_lnum or diagnostic.lnum, diagnostic.end_col or diagnostic.col)
  local cursor = vim.pos(bufnr, lnum, col)

  if start == finish then
    return cursor == start
  end

  return start <= cursor and cursor < finish
end

--- Selects a code action (LSP: "textDocument/codeAction" request) available at cursor position.
---
---@param opts? vim.lsp.buf.code_action.Opts
---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction
---@see vim.lsp.protocol.CodeActionTriggerKind
function M.code_action(opts)
  validate('options', opts, 'table', true)
  opts = opts or {}
  -- Detect old API call code_action(context) which should now be
  -- code_action({ context = context} )
  --- @diagnostic disable-next-line:undefined-field
  if opts.diagnostics or opts.only then
    opts = { options = opts }
  end
  local context = opts.context and vim.deepcopy(opts.context) or {}
  if not context.triggerKind then
    context.triggerKind = lsp.protocol.CodeActionTriggerKind.Invoked
  end
  local mode = api.nvim_get_mode().mode
  local bufnr = api.nvim_get_current_buf()
  local win = api.nvim_get_current_win()
  local range = opts.range
  if range == nil and (mode == 'v' or mode == 'V') then
    range = range_from_selection(bufnr, mode)
  end
  local cursor = api.nvim_win_get_cursor(win)
  local lnum = cursor[1] - 1
  local col = cursor[2]
  local clients = lsp.get_clients({ bufnr = bufnr, method = 'textDocument/codeAction' })
  if not next(clients) then
    vim.notify(lsp._unsupported_method('textDocument/codeAction'), vim.log.levels.WARN)
    return
  end

  lsp.buf_request_all(bufnr, 'textDocument/codeAction', function(client)
    ---@type lsp.CodeActionParams
    local params

    if range then
      assert(type(range) == 'table', 'code_action range must be a table')
      local start = assert(range.start, 'range must have a `start` property')
      local end_ = assert(range['end'], 'range must have a `end` property')
      params = util.make_given_range_params(start, end_, bufnr, client.offset_encoding)
    else
      params = util.make_range_params(win, client.offset_encoding)
    end

    --- @cast params lsp.CodeActionParams

    if context.diagnostics then
      params.context = context
    else
      local ns_push = lsp.diagnostic.get_namespace(client.id)
      local diagnostics = {}

      client:_provider_foreach('textDocument/diagnostic', function(cap)
        local ns_pull = lsp.diagnostic.get_namespace(client.id, true, cap.identifier)
        vim.list_extend(
          diagnostics,
          vim.diagnostic.get(bufnr, { namespace = ns_pull, lnum = lnum })
        )
      end)

      vim.list_extend(diagnostics, vim.diagnostic.get(bufnr, { namespace = ns_push, lnum = lnum }))
      if range == nil then
        diagnostics = vim.tbl_filter(function(diagnostic)
          return diagnostic_contains_cursor(diagnostic, bufnr, lnum, col)
        end, diagnostics)
      end
      params.context = vim.tbl_extend('force', context, {
        ---@diagnostic disable-next-line: no-unknown
        diagnostics = vim.tbl_map(function(d)
          return d.user_data.lsp
        end, diagnostics),
      })
    end

    return params
  end, function(results)
    on_code_action_results(results, opts)
  end)
end

---@type { index: integer, ranges: lsp.Range[] }?
local selection_ranges = nil

---@param range lsp.Range
local function select_range(range)
  local start_line = range.start.line
  local end_line = range['end'].line

  local start_col = range.start.character
  local end_col = range['end'].character

  Range.visual_select({ start_line, start_col, end_line, end_col })
end

---@param range lsp.Range
local function is_empty(range)
  return range.start.line == range['end'].line and range.start.character == range['end'].character
end

--- [lsp-incremental-selection]()
---
--- Expands or contracts a |Visual| selection at cursor, based on ranges given by LSP. The
--- `direction` parameter specifies the number of times to expand the selection. Negative values
--- will shrink the selection.
---
--- @param direction integer
--- @param timeout_ms integer? (default: `1000`) Maximum time (milliseconds) to wait for a result.
function M.selection_range(direction, timeout_ms)
  validate('direction', direction, 'number')
  validate('timeout_ms', timeout_ms, 'number', true)

  if selection_ranges then
    local new_index = selection_ranges.index + direction
    selection_ranges.index = math.min(#selection_ranges.ranges, math.max(1, new_index))

    select_range(selection_ranges.ranges[selection_ranges.index])
    return
  end

  local method = 'textDocument/selectionRange'
  local client = lsp.get_clients({ method = method, bufnr = 0 })[1]
  if not client then
    vim.notify(lsp._unsupported_method(method), vim.log.levels.WARN)
    return
  end

  local position_params = util.make_position_params(0, client.offset_encoding)

  ---@type lsp.SelectionRangeParams
  local params = {
    textDocument = position_params.textDocument,
    positions = { position_params.position },
  }

  timeout_ms = timeout_ms or 1000
  local result, err = lsp.buf_request_sync(0, method, params, timeout_ms)
  if err then
    local err_message = type(err) == 'table' and lsp.rpc.format_rpc_error(err) or err
    lsp.log.error('selectionRange request failed: ' .. err_message)
    return
  end
  if not result or not result[client.id] or not result[client.id].result then
    return
  end
  if result[client.id].error then
    lsp.log.error(result[client.id].error.code, result[client.id].error.message)
  end

  -- We only requested one range, thus we get the first and only response here.
  local response = assert(result[client.id].result[1]) ---@type lsp.SelectionRange
  local ranges = {} ---@type lsp.Range[]
  local lines = api.nvim_buf_get_lines(0, 0, -1, false)

  -- Populate the list of ranges from the given request.
  while response do
    local range = response.range
    if not is_empty(range) then
      local start_line = range.start.line
      local end_line = range['end'].line
      range.start.character = vim.str_byteindex(
        lines[start_line + 1] or '',
        client.offset_encoding,
        range.start.character,
        false
      )
      range['end'].character = vim.str_byteindex(
        lines[end_line + 1] or '',
        client.offset_encoding,
        range['end'].character,
        false
      )
      ranges[#ranges + 1] = range
    end
    response = response.parent
  end

  -- Clear selection ranges when leaving visual mode.
  nvim_on('ModeChanged', nil, { once = true, pattern = 'v*:*' }, function()
    selection_ranges = nil
  end)

  if #ranges > 0 then
    local index = math.min(#ranges, math.max(1, direction))
    selection_ranges = { index = index, ranges = ranges }
    select_range(ranges[index])
  end
end

return M
