local api, nonnil = vim.api, vim.nonnil
local shared = require('vim.diagnostic._shared')
local store = require('vim.diagnostic._store')

--- @class (private) vim.diagnostic._float
local M = {}

local severity = vim.diagnostic.severity

--- @type table<vim.diagnostic.Severity, string>
local floating_highlight_map = {
  [severity.ERROR] = 'DiagnosticFloatingError',
  [severity.WARN] = 'DiagnosticFloatingWarn',
  [severity.INFO] = 'DiagnosticFloatingInfo',
  [severity.HINT] = 'DiagnosticFloatingHint',
}

--- @param opts vim.diagnostic.Opts.Float
--- @param bufnr integer
--- @return vim.diagnostic.Opts.Float, vim.diagnostic.Opts
local function resolve_float_opts(opts, bufnr)
  -- Resolve options with user settings from vim.diagnostic.config
  -- Unlike the other decoration functions (e.g. set_virtual_text, set_signs, etc.) `open_float`
  -- does not have a dedicated table for configuration options; instead, the options are mixed in
  -- with its `opts` table. We create a dedicated options table (`float_opts`) that inherits
  -- missing keys from the global configuration (`global_diagnostic_options.float`), which can
  -- be a table or a function.
  local global_opts = assert(vim.diagnostic.config())
  local float_opts = global_opts.float
  local resolved_float_opts = type(float_opts) == 'table' and float_opts
    or (type(float_opts) == 'function' and float_opts(opts.namespace, bufnr) or {})

  return vim.tbl_extend('keep', opts, resolved_float_opts), global_opts
end

--- @param opts vim.diagnostic.Opts.Float?
--- @return integer? float_bufnr
--- @return integer? winid
function M.open(opts, ...)
  -- Support old (bufnr, opts) signature
  local bufnr --- @type integer?
  if opts == nil or type(opts) == 'number' then
    bufnr = opts
    opts = ... --- @type vim.diagnostic.Opts.Float
  else
    vim.validate('opts', opts, 'table', true)
  end

  opts = opts or {}
  bufnr = vim._resolve_bufnr(bufnr or opts.bufnr)
  local global_opts --- @type vim.diagnostic.Opts
  opts, global_opts = resolve_float_opts(opts, bufnr)

  local scope = ({ l = 'line', c = 'cursor', b = 'buffer' })[opts.scope] or opts.scope or 'line'
  local lnum, col --- @type integer, integer
  local opts_pos = opts.pos
  if scope == 'line' or scope == 'cursor' then
    if not opts_pos then
      local pos = api.nvim_win_get_cursor(0)
      lnum = pos[1] - 1
      col = pos[2]
    elseif type(opts_pos) == 'number' then
      lnum = opts_pos
    elseif type(opts_pos) == 'table' then
      lnum, col = opts_pos[1], opts_pos[2]
    else
      error("Invalid value for option 'pos'")
    end
  elseif scope ~= 'buffer' then
    error("Invalid value for option 'scope'")
  end

  local diagnostics = store.get_diagnostics(bufnr, opts --[[@as vim.diagnostic.GetOpts]], true)

  if scope == 'line' then
    --- @param diagnostic vim.Diagnostic
    local function line_filter(diagnostic)
      local d_lnum, _, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
      return lnum >= d_lnum
        and lnum <= d_end_lnum
        and (d_lnum == d_end_lnum or lnum ~= d_end_lnum or d_end_col ~= 0)
    end
    diagnostics = vim.tbl_filter(line_filter, diagnostics)
  elseif scope == 'cursor' then
    -- If `col` is past the end of the line, show if the cursor is on the last char in the line
    local line_length = #api.nvim_buf_get_lines(bufnr, lnum, lnum + 1, true)[1]
    --- @param diagnostic vim.Diagnostic
    local function cursor_filter(diagnostic)
      local d_lnum, d_col, d_end_lnum, d_end_col = shared.get_logical_pos(diagnostic)
      return lnum >= d_lnum
        and lnum <= d_end_lnum
        and (lnum ~= d_lnum or col >= math.min(d_col, line_length - 1))
        and ((d_lnum == d_end_lnum and d_col == d_end_col) or lnum ~= d_end_lnum or col < d_end_col)
    end
    diagnostics = vim.tbl_filter(cursor_filter, diagnostics)
  end

  if vim.tbl_isempty(diagnostics) then
    return
  end

  local severity_sort = nonnil(opts.severity_sort, global_opts.severity_sort)
  if severity_sort then
    if type(severity_sort) == 'table' and severity_sort.reverse then
      table.sort(diagnostics, function(a, b)
        return shared.diagnostic_cmp(a, b, 'severity', true)
      end)
    else
      table.sort(diagnostics, function(a, b)
        return shared.diagnostic_cmp(a, b, 'severity', false)
      end)
    end
  end

  local lines = {} --- @type string[]
  local highlights = {} --- @type { hlname: string, prefix?: { length: integer, hlname: string? }, suffix?: { length: integer, hlname: string? } }[]
  local header = nonnil(opts.header, 'Diagnostics:')
  if header then
    vim.validate('header', header, { 'string', 'table' }, "'string' or 'table'")
    if type(header) == 'table' then
      -- Don't insert any lines for an empty string
      if #(header[1] or '') > 0 then
        lines[#lines + 1] = header[1]
        highlights[#highlights + 1] = { hlname = header[2] or 'Bold' }
      end
    elseif #header > 0 then
      lines[#lines + 1] = header
      highlights[#highlights + 1] = { hlname = 'Bold' }
    end
  end

  if opts.format then
    diagnostics = shared.reformat_diagnostics(opts.format, diagnostics)
  end

  if opts.source and (opts.source ~= 'if_many' or shared.count_sources(bufnr) > 1) then
    diagnostics = shared.prefix_source(diagnostics)
  end

  local prefix_opt = opts.prefix
    or (scope == 'cursor' and #diagnostics <= 1) and ''
    or function(_, i)
      return string.format('%d. ', i)
    end

  local prefix, prefix_hl_group --- @type string?, string?
  if prefix_opt then
    vim.validate(
      'prefix',
      prefix_opt,
      { 'string', 'table', 'function' },
      "'string' or 'table' or 'function'"
    )
    if type(prefix_opt) == 'string' then
      prefix, prefix_hl_group = prefix_opt, 'NormalFloat'
    elseif type(prefix_opt) == 'table' then
      prefix, prefix_hl_group = prefix_opt[1] or '', prefix_opt[2] or 'NormalFloat'
    end
  end

  local suffix_opt = opts.suffix
    or function(diagnostic)
      return diagnostic.code and string.format(' [%s]', diagnostic.code) or ''
    end

  local suffix, suffix_hl_group --- @type string?, string?
  if suffix_opt then
    vim.validate(
      'suffix',
      suffix_opt,
      { 'string', 'table', 'function' },
      "'string' or 'table' or 'function'"
    )
    if type(suffix_opt) == 'string' then
      suffix, suffix_hl_group = suffix_opt, 'NormalFloat'
    elseif type(suffix_opt) == 'table' then
      suffix, suffix_hl_group = suffix_opt[1] or '', suffix_opt[2] or 'NormalFloat'
    end
  end

  local related_info_locations = {} --- @type table<integer, lsp.Location>
  for i, diagnostic in ipairs(diagnostics) do
    if type(prefix_opt) == 'function' then
      local prefix0, prefix_hl_group0 = prefix_opt(diagnostic, i, #diagnostics)
      prefix, prefix_hl_group = prefix0 or '', prefix_hl_group0 or 'NormalFloat'
    end
    if type(suffix_opt) == 'function' then
      local suffix0, suffix_hl_group0 = suffix_opt(diagnostic, i, #diagnostics)
      suffix, suffix_hl_group = suffix0 or '', suffix_hl_group0 or 'NormalFloat'
    end

    local hiname = floating_highlight_map[diagnostic.severity]
    local message_lines = vim.split(diagnostic.message, '\n')
    local default_pre = string.rep(' ', #prefix)
    for j = 1, #message_lines do
      local pre = j == 1 and prefix or default_pre
      local suf = j == #message_lines and suffix or ''
      lines[#lines + 1] = pre .. message_lines[j] .. suf
      highlights[#highlights + 1] = {
        hlname = hiname,
        prefix = {
          length = j == 1 and #prefix or 0,
          hlname = prefix_hl_group,
        },
        suffix = {
          length = #suf,
          hlname = suffix_hl_group,
        },
      }
    end

    --- @type lsp.DiagnosticRelatedInformation[]
    local related_info = vim.tbl_get(diagnostic, 'user_data', 'lsp', 'relatedInformation') or {}
    -- Below the diagnostic, show its LSP related information (if any) in the form of file name and
    -- range, plus description.
    for _, info in ipairs(related_info) do
      local location = info.location
      local file_name = vim.fs.basename(vim.uri_to_fname(location.uri))
      local info_suffix = ': ' .. info.message
      related_info_locations[#lines + 1] = location
      lines[#lines + 1] = string.format(
        '%s%s:%s:%s%s',
        default_pre,
        file_name,
        location.range.start.line + 1,
        location.range.start.character + 1,
        info_suffix
      )
      highlights[#highlights + 1] = {
        hlname = '@string.special.path',
        prefix = {
          length = #default_pre,
          hlname = prefix_hl_group,
        },
        suffix = {
          length = #info_suffix,
          hlname = 'NormalFloat',
        },
      }
    end
  end

  -- Used by open_floating_preview to allow the float to be focused
  if not opts.focus_id then
    opts.focus_id = scope
  end

  --- @diagnostic disable-next-line: param-type-mismatch
  local float_bufnr, winnr = vim.lsp.util.open_floating_preview(lines, 'plaintext', opts)
  vim.bo[float_bufnr].path = vim.bo[bufnr].path

  -- TODO: Handle this generally (like vim.ui.open()), rather than overriding gf.
  vim.keymap.set('n', 'gf', function()
    local cursor_row = api.nvim_win_get_cursor(0)[1]
    local location = related_info_locations[cursor_row]
    if location then
      -- Split the window before calling `show_document` so the window doesn't disappear.
      vim.cmd.split()
      vim.lsp.util.show_document(location, 'utf-16', { focus = true })
    else
      vim.cmd.normal({ 'gf', bang = true })
    end
  end, { buf = float_bufnr, remap = false })

  --- @diagnostic disable-next-line: deprecated
  local add_highlight = api.nvim_buf_add_highlight

  for i, hl in ipairs(highlights) do
    local line = lines[i]
    local prefix_len = hl.prefix and hl.prefix.length or 0
    local suffix_len = hl.suffix and hl.suffix.length or 0
    if prefix_len > 0 then
      add_highlight(float_bufnr, -1, hl.prefix.hlname, i - 1, 0, prefix_len)
    end
    add_highlight(float_bufnr, -1, hl.hlname, i - 1, prefix_len, #line - suffix_len)
    if suffix_len > 0 then
      add_highlight(float_bufnr, -1, hl.suffix.hlname, i - 1, #line - suffix_len, -1)
    end
  end

  return float_bufnr, winnr
end

return M
