-- Generates lua-ls annotations for lsp.

local USAGE = [[
Generates lua-ls annotations for lsp.

USAGE:
nvim -l scripts/gen_lsp.lua gen  # by default, this will overwrite runtime/lua/vim/lsp/_meta/protocol.lua
nvim -l scripts/gen_lsp.lua gen --version 3.18 --out runtime/lua/vim/lsp/_meta/protocol.lua
nvim -l scripts/gen_lsp.lua gen --version 3.18 --methods
]]

local DEFAULT_LSP_VERSION = '3.18'

local M = {}

local function tofile(fname, text)
  local f = io.open(fname, 'w')
  if not f then
    error(('failed to write: %s'):format(f))
  else
    print(('Written to: %s'):format(fname))
    f:write(text)
    f:close()
  end
end

--- The LSP protocol JSON data (it's partial, non-exhaustive).
--- https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/3.18/metaModel/metaModel.schema.json
--- @class vim._gen_lsp.Protocol
--- @field requests vim._gen_lsp.Request[]
--- @field notifications vim._gen_lsp.Notification[]
--- @field structures vim._gen_lsp.Structure[]
--- @field enumerations vim._gen_lsp.Enumeration[]
--- @field typeAliases vim._gen_lsp.TypeAlias[]

---@param opt vim._gen_lsp.opt
---@return vim._gen_lsp.Protocol
local function read_json(opt)
  local uri = 'https://raw.githubusercontent.com/microsoft/language-server-protocol/gh-pages/_specifications/lsp/'
    .. opt.version
    .. '/metaModel/metaModel.json'
  print('Reading ' .. uri)

  local res = vim.system({ 'curl', '--no-progress-meter', uri, '-o', '-' }):wait()
  if res.code ~= 0 or (res.stdout or ''):len() < 999 then
    print(('URL failed: %s'):format(uri))
    vim.print(res)
    error(res.stdout)
  end
  return vim.json.decode(res.stdout)
end

-- Gets the Lua symbol for a given fully-qualified LSP method name.
local function to_luaname(s)
  -- "$/" prefix is special: https://microsoft.github.io/language-server-protocol/specification/#dollarRequests
  return s:gsub('^%$', 'dollar'):gsub('/', '_')
end

---@param protocol vim._gen_lsp.Protocol
local function gen_methods(protocol)
  local output = {
    '-- Generated by gen_lsp.lua, keep at end of file.',
    '--- LSP method names.',
    '---',
    '---@see https://microsoft.github.io/language-server-protocol/specification/#metaModel',
    'protocol.Methods = {',
  }
  local indent = (' '):rep(2)

  --- @class vim._gen_lsp.Request
  --- @field deprecated? string
  --- @field documentation? string
  --- @field messageDirection string
  --- @field method string
  --- @field params? any
  --- @field proposed? boolean
  --- @field registrationMethod? string
  --- @field registrationOptions? any
  --- @field since? string

  --- @class vim._gen_lsp.Notification
  --- @field deprecated? string
  --- @field documentation? string
  --- @field errorData? any
  --- @field messageDirection string
  --- @field method string
  --- @field params? any[]
  --- @field partialResult? any
  --- @field proposed? boolean
  --- @field registrationMethod? string
  --- @field registrationOptions? any
  --- @field result any
  --- @field since? string

  ---@type (vim._gen_lsp.Request|vim._gen_lsp.Notification)[]
  local all = vim.list_extend(protocol.requests, protocol.notifications)
  table.sort(all, function(a, b)
    return to_luaname(a.method) < to_luaname(b.method)
  end)
  for _, item in ipairs(all) do
    if item.method then
      if item.documentation then
        local document = vim.split(item.documentation, '\n?\n', { trimempty = true })
        for _, docstring in ipairs(document) do
          output[#output + 1] = indent .. '--- ' .. docstring
        end
      end
      output[#output + 1] = ("%s%s = '%s',"):format(indent, to_luaname(item.method), item.method)
    end
  end
  output[#output + 1] = '}'
  output = vim.list_extend(
    output,
    vim.split(
      [[
local function freeze(t)
  return setmetatable({}, {
    __index = t,
    __newindex = function()
      error('cannot modify immutable table')
    end,
  })
end
protocol.Methods = freeze(protocol.Methods)

return protocol
]],
      '\n',
      { trimempty = true }
    )
  )

  local fname = './runtime/lua/vim/lsp/protocol.lua'
  local bufnr = vim.fn.bufadd(fname)
  vim.fn.bufload(bufnr)
  vim.api.nvim_set_current_buf(bufnr)
  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
  local index = vim.iter(ipairs(lines)):find(function(key, item)
    return vim.startswith(item, '-- Generated by') and key or nil
  end)
  index = index and index - 1 or vim.api.nvim_buf_line_count(bufnr) - 1
  vim.api.nvim_buf_set_lines(bufnr, index, -1, true, output)
  vim.cmd.write()
end

---@class vim._gen_lsp.opt
---@field output_file string
---@field version string
---@field methods boolean

---@param opt vim._gen_lsp.opt
function M.gen(opt)
  --- @type vim._gen_lsp.Protocol
  local protocol = read_json(opt)

  if opt.methods then
    gen_methods(protocol)
  end

  local output = {
    '--' .. '[[',
    'THIS FILE IS GENERATED by scripts/gen_lsp.lua',
    'DO NOT EDIT MANUALLY',
    '',
    'Based on LSP protocol ' .. opt.version,
    '',
    'Regenerate:',
    ([=[nvim -l scripts/gen_lsp.lua gen --version %s]=]):format(DEFAULT_LSP_VERSION),
    '--' .. ']]',
    '',
    '---@meta',
    "error('Cannot require a meta file')",
    '',
    '---@alias lsp.null nil',
    '---@alias uinteger integer',
    '---@alias decimal number',
    '---@alias lsp.DocumentUri string',
    '---@alias lsp.URI string',
    '',
  }

  local anonymous_num = 0

  ---@type string[]
  local anonym_classes = {}

  local simple_types = {
    'string',
    'boolean',
    'integer',
    'uinteger',
    'decimal',
  }

  ---@param documentation string
  local _process_documentation = function(documentation)
    documentation = documentation:gsub('\n', '\n---')
    -- Remove <200b> (zero-width space) unicode characters: e.g., `**/<200b>*`
    documentation = documentation:gsub('\226\128\139', '')
    -- Escape annotations that are not recognized by lua-ls
    documentation = documentation:gsub('%^---@sample', '---\\@sample')
    return '---' .. documentation
  end

  --- @class vim._gen_lsp.Type
  --- @field kind string a common field for all Types.
  --- @field name? string for ReferenceType, BaseType
  --- @field element? any for ArrayType
  --- @field items? vim._gen_lsp.Type[] for OrType, AndType
  --- @field key? vim._gen_lsp.Type for MapType
  --- @field value? string|vim._gen_lsp.Type for StringLiteralType, MapType, StructureLiteralType

  ---@param type vim._gen_lsp.Type
  ---@param prefix? string Optional prefix associated with the this type, made of (nested) field name.
  ---             Used to generate class name for structure literal types.
  ---@return string
  local function parse_type(type, prefix)
    -- ReferenceType | BaseType
    if type.kind == 'reference' or type.kind == 'base' then
      if vim.tbl_contains(simple_types, type.name) then
        return type.name
      end
      return 'lsp.' .. type.name

    -- ArrayType
    elseif type.kind == 'array' then
      local parsed_items = parse_type(type.element, prefix)
      if type.element.items and #type.element.items > 1 then
        parsed_items = '(' .. parsed_items .. ')'
      end
      return parsed_items .. '[]'

    -- OrType
    elseif type.kind == 'or' then
      local val = ''
      for _, item in ipairs(type.items) do
        val = val .. parse_type(item, prefix) .. '|' --[[ @as string ]]
      end
      val = val:sub(0, -2)
      return val

    -- StringLiteralType
    elseif type.kind == 'stringLiteral' then
      return '"' .. type.value .. '"'

    -- MapType
    elseif type.kind == 'map' then
      local key = assert(type.key)
      local value = type.value --[[ @as vim._gen_lsp.Type ]]
      return 'table<' .. parse_type(key, prefix) .. ', ' .. parse_type(value, prefix) .. '>'

    -- StructureLiteralType
    elseif type.kind == 'literal' then
      -- can I use ---@param disabled? {reason: string}
      -- use | to continue the inline class to be able to add docs
      -- https://github.com/LuaLS/lua-language-server/issues/2128
      anonymous_num = anonymous_num + 1
      local anonymous_classname = 'lsp._anonym' .. anonymous_num
      if prefix then
        anonymous_classname = anonymous_classname .. '.' .. prefix
      end
      local anonym = vim
        .iter({
          (anonymous_num > 1 and { '' } or {}),
          { '---@class ' .. anonymous_classname },
        })
        :flatten()
        :totable()

      --- @class vim._gen_lsp.StructureLiteral translated to anonymous @class.
      --- @field deprecated? string
      --- @field description? string
      --- @field properties vim._gen_lsp.Property[]
      --- @field proposed? boolean
      --- @field since? string

      ---@type vim._gen_lsp.StructureLiteral
      local structural_literal = assert(type.value) --[[ @as vim._gen_lsp.StructureLiteral ]]
      for _, field in ipairs(structural_literal.properties) do
        anonym[#anonym + 1] = '---'
        if field.documentation then
          anonym[#anonym + 1] = _process_documentation(field.documentation)
        end
        anonym[#anonym + 1] = '---@field '
          .. field.name
          .. (field.optional and '?' or '')
          .. ' '
          .. parse_type(field.type, prefix .. '.' .. field.name)
      end
      -- anonym[#anonym + 1] = ''
      for _, line in ipairs(anonym) do
        if line then
          anonym_classes[#anonym_classes + 1] = line
        end
      end
      return anonymous_classname

    -- TupleType
    elseif type.kind == 'tuple' then
      local tuple = '{ '
      for i, value in ipairs(type.items) do
        tuple = tuple .. '[' .. i .. ']: ' .. parse_type(value, prefix) .. ', '
      end
      -- remove , at the end
      tuple = tuple:sub(0, -3)
      return tuple .. ' }'
    end

    vim.print('WARNING: Unknown type ', type)
    return ''
  end

  --- @class vim._gen_lsp.Structure translated to @class
  --- @field deprecated? string
  --- @field documentation? string
  --- @field extends? { kind: string, name: string }[]
  --- @field mixins? { kind: string, name: string }[]
  --- @field name string
  --- @field properties? vim._gen_lsp.Property[]  members, translated to @field
  --- @field proposed? boolean
  --- @field since? string
  for _, structure in ipairs(protocol.structures) do
    -- output[#output + 1] = ''
    if structure.documentation then
      output[#output + 1] = _process_documentation(structure.documentation)
    end
    local class_string = ('---@class lsp.%s'):format(structure.name)
    if structure.extends or structure.mixins then
      local inherits_from = table.concat(
        vim.list_extend(
          vim.tbl_map(parse_type, structure.extends or {}),
          vim.tbl_map(parse_type, structure.mixins or {})
        ),
        ', '
      )
      class_string = class_string .. ': ' .. inherits_from
    end
    output[#output + 1] = class_string

    --- @class vim._gen_lsp.Property translated to @field
    --- @field deprecated? string
    --- @field documentation? string
    --- @field name string
    --- @field optional? boolean
    --- @field proposed? boolean
    --- @field since? string
    --- @field type { kind: string, name: string }
    for _, field in ipairs(structure.properties or {}) do
      output[#output + 1] = '---' -- Insert a single newline between @fields (and after @class)
      if field.documentation then
        output[#output + 1] = _process_documentation(field.documentation)
      end
      output[#output + 1] = '---@field '
        .. field.name
        .. (field.optional and '?' or '')
        .. ' '
        .. parse_type(field.type, field.name)
    end
    output[#output + 1] = ''
  end

  --- @class vim._gen_lsp.Enumeration translated to @enum
  --- @field deprecated string?
  --- @field documentation string?
  --- @field name string?
  --- @field proposed boolean?
  --- @field since string?
  --- @field suportsCustomValues boolean?
  --- @field values { name: string, value: string, documentation?: string, since?: string }[]
  for _, enum in ipairs(protocol.enumerations) do
    if enum.documentation then
      output[#output + 1] = _process_documentation(enum.documentation)
    end
    local enum_type = '---@alias lsp.' .. enum.name
    for _, value in ipairs(enum.values) do
      enum_type = enum_type
        .. '\n---| '
        .. (type(value.value) == 'string' and '"' .. value.value .. '"' or value.value)
        .. ' # '
        .. value.name
    end
    output[#output + 1] = enum_type
    output[#output + 1] = ''
  end

  --- @class vim._gen_lsp.TypeAlias translated to @alias
  --- @field deprecated? string?
  --- @field documentation? string
  --- @field name string
  --- @field proposed? boolean
  --- @field since? string
  --- @field type vim._gen_lsp.Type
  for _, alias in ipairs(protocol.typeAliases) do
    if alias.documentation then
      output[#output + 1] = _process_documentation(alias.documentation)
    end
    if alias.type.kind == 'or' then
      local alias_type = '---@alias lsp.' .. alias.name .. ' '
      for _, item in ipairs(alias.type.items) do
        alias_type = alias_type .. parse_type(item, alias.name) .. '|'
      end
      alias_type = alias_type:sub(0, -2)
      output[#output + 1] = alias_type
    else
      output[#output + 1] = '---@alias lsp.'
        .. alias.name
        .. ' '
        .. parse_type(alias.type, alias.name)
    end
    output[#output + 1] = ''
  end

  -- anonymous classes
  for _, line in ipairs(anonym_classes) do
    output[#output + 1] = line
  end

  tofile(opt.output_file, table.concat(output, '\n') .. '\n')
end

---@type vim._gen_lsp.opt
local opt = {
  output_file = 'runtime/lua/vim/lsp/_meta/protocol.lua',
  version = DEFAULT_LSP_VERSION,
  methods = false,
}

local command = nil
local i = 1
while i <= #_G.arg do
  if _G.arg[i] == '--out' then
    opt.output_file = assert(_G.arg[i + 1], '--out <outfile> needed')
    i = i + 1
  elseif _G.arg[i] == '--version' then
    opt.version = assert(_G.arg[i + 1], '--version <version> needed')
    i = i + 1
  elseif _G.arg[i] == '--methods' then
    opt.methods = true
  elseif vim.startswith(_G.arg[i], '-') then
    error('Unrecognized args: ' .. _G.arg[i])
  else
    if command then
      error('More than one command was given: ' .. _G.arg[i])
    else
      command = _G.arg[i]
    end
  end
  i = i + 1
end

if not command then
  print(USAGE)
elseif M[command] then
  M[command](opt) -- see M.gen()
else
  error('Unknown command: ' .. command)
end

return M
