VERSION = "0.2.0"

local micro = import("micro")
local config = import("micro/config")
local shell = import("micro/shell")
local buffer = import("micro/buffer")
local util = import("micro/util")
local go_os = import("os")
local go_strings = import("strings")
local go_time = import("time")
local filepath = import("path/filepath")

local settings = settings
local json = json

function init()
    -- ordering of the table affects the autocomplete suggestion order
    local subcommands = {
        ["start"]               = startServer,
        ["stop"]                = stopServers,
        ["diagnostic-info"]     = openDiagnosticBufferAction,
        ["document-symbols"]    = documentSymbolsAction,
        ["find-references"]     = findReferencesAction,
        ["format"]              = formatAction,
        ["goto-definition"]     = gotoAction("definition"),
        ["goto-declaration"]    = gotoAction("declaration"),
        ["goto-implementation"] = gotoAction("implementation"),
        ["goto-typedefinition"] = gotoAction("typeDefinition"),
        ["hover"]               = hoverAction,
        ["sync-document"]       = function (bp) syncFullDocument(bp.Buf) end,
        ["autocomplete"]        = completionAction,
        ["showlog"]             = showLog,
    }

    local lspCompleter = function (buf)
        -- Do NOT autocomplete after first argument
        -- TODO: autocomplete "lsp start " and "lsp stop "
        local args = go_strings.Split(buf:Line(0), " ")
        if #args > 2 then return nil, nil end

        local suggestions = {}
        local completions = {}
        local lastArg = args[#args]

        for subcommand, _ in pairs(subcommands) do
            local startIdx, endIdx = string.find(subcommand, lastArg, 1, true)
            if startIdx == 1 then
                local completion = string.sub(subcommand, endIdx + 1, #subcommand)
                table.insert(completions, completion)
                table.insert(suggestions, subcommand)
            end
        end

        return completions, suggestions
    end

    local lspCommand = function(bp, argsUserdata)
        local args = {}
        for _, a in userdataIterator(argsUserdata) do table.insert(args, a) end

        if #args == 0 then
            startServer(bp, {})
            return
        end

        local subcommand = table.remove(args, 1)
        local func = subcommands[subcommand]
        if func then
            func(bp, args)
        else
            display_error(string.format("Unknown subcommand '%s'", subcommand))
        end
    end

    micro.SetStatusInfoFn("mlsp.status")
    config.MakeCommand("lsp", lspCommand, lspCompleter)
end

local activeConnections = {}
local allConnections = {}
setmetatable(allConnections, { __index = function (_, k) return activeConnections[k] end })
local docBuffers = {}
local lastAutocompletion = -1
local undoStackLengthBefore = 0

local LSPClient = {}
LSPClient.__index = LSPClient

local LSPRange = {
    fromSelection = function(selection)
        -- create Range https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range
        -- from [2]Loc https://pkg.go.dev/github.com/zyedidia/micro/v2@v2.0.12/internal/buffer#Cursor
        return {
            ["start"] = { line = selection[1].Y, character = selection[1].X },
            ["end"]   = { line = selection[2].Y, character = selection[2].X }
        }
    end,
    fromDelta = function(delta)
        local deltaEnd = delta.End
        -- for some reason delta.End is often 0,0 when inserting characters
        if deltaEnd.Y == 0 and deltaEnd.X == 0 then
            deltaEnd = delta.Start
        end

        return {
            ["start"] = { line = delta.Start.Y, character = delta.Start.X },
            ["end"]   = { line = deltaEnd.Y, character = deltaEnd.X }
        }
    end,
    toLocs = function(range)
        local a, b = range["start"], range["end"]
        return buffer.Loc(a.character, a.line), buffer.Loc(b.character, b.line)
    end
}

function status(buf)
    local servers = {}
    for _, client in pairs(activeConnections) do
        table.insert(servers, client.clientId)
    end
    if #servers == 0 then
        return "off"
    elseif #servers == 1 then
        return servers[1]
    else
        return string.format("[%s]", table.concat(servers, ","))
    end
end

function startServer(bufpane, args)
    local server
    if next(args) ~= nil then
        local cmd = table.remove(args, 1)
        -- prefer languageServer with given name from config.lua if no args given
        if next(args) == nil and languageServer[cmd] ~= nil then
            server = languageServer[cmd]
        else
            server = languageServer[cmd] or { cmd = cmd, args = args }
        end
    else
        local ftype = bufpane.Buf:FileType()
        server = settings.defaultLanguageServer[ftype]
        if server == nil then
            display_error(string.format("No language server set up for file type '%s'", ftype))
            return
        end
    end

    LSPClient:initialize(server)
end

function stopServers(bufpane, args)
    local name = args[1]
    if not name then -- stop all
        for clientId, client in pairs(activeConnections) do
            client:stop()
        end
        activeConnections = {}
    elseif activeConnections[name] then
        activeConnections[name]:stop()
        activeConnections[name] = nil
    else
        display_error(string.format("No active language server with name '%s'", name))
    end
end

function showLog(bufpane, args)
    local hasArgs, name = pcall(function() return args[1] end)

    for _, client in pairs(activeConnections) do
        if not hasArgs or client.name == name then
            foundClient = client
            break
        end
    end

    if foundClient == nil then
        display_info("No LSP client found")
        return
    end

    if foundClient.stderr == "" then
        display_info(foundClient.clientId, " has not written anything to stderr")
        return
    end

    local title = string.format("[µlsp] Log for '%s' (%s)", foundClient.name, foundClient.clientId)
    local newBuffer = buffer.NewBuffer(foundClient.stderr, title)

    newBuffer:SetOption("filetype", "text")
    newBuffer.Type.scratch = true
    newBuffer.Type.Readonly = true

    micro.CurPane():HSplitBuf(newBuffer)
end

function LSPClient:initialize(server)
    local clientId = server.shortName or server.cmd

    if allConnections[clientId] ~= nil then
        display_info(clientId, " is already running")
        return
    end

    local client = {}
    setmetatable(client, LSPClient)

    allConnections[clientId] = client

    client.clientId = clientId
    client.requestId = 0
    client.stderr = ""
    client.buffer = ""
    client.expectedLength = nil
    client.serverCapabilities = {}
    client.serverName = nil
    client.serverVersion = nil
    client.sentRequests = {}
    client.openFiles = {}
    client.onInitialized = server.onInitialized

    -- the last parameter(s) to JobSpawn are userargs which get passed down to
    -- the callback functions (onStdout, onStderr, onExit)
    client.job = shell.JobSpawn(server.cmd, server.args, onStdout, onStderr, onExit, clientId)
    if client.job.Err ~= nil then
        return
    end
    log(string.format("Started '%s' with args", server.cmd), server.args)

    local wd, _ = go_os.Getwd()
    local rootUri = string.format("file://%s", wd:uriEncode())

    local params = {
        processId = go_os.Getpid(),
        rootUri = rootUri,
        workspaceFolders = { { name = "root", uri = rootUri } },
        capabilities = {
            textDocument = {
                synchronization = { didSave = true, willSave = false },
                hover = { contentFormat = {"plaintext"} },
                completion = {
                    completionItem = {
                        snippetSupport = false,
                        documentationFormat = {},
                    },
                    contextSupport = true
                }
            }
        }
    }
    if server.initializationOptions ~= nil then
        params.initializationOptions = server.initializationOptions
    end

    client:request("initialize", params)
    return client
end

function LSPClient:stop()
    for filePath, _ in pairs(self.openFiles) do
        for _, docBuf in ipairs(docBuffers[filePath]) do
            docBuf:ClearMessages(self.clientId)
        end
    end
    log("stopped", self.clientId)
    display_info(self.clientId, " stopped")
    shell.JobStop(self.job)
end

function LSPClient:send(msg)
    msg = json.encode(msg)
    local msgWithHeaders = string.format("Content-Length: %d\r\n\r\n%s", #msg, msg)
    shell.JobSend(self.job, msgWithHeaders)
    log("(", self.clientId, ")->", msgWithHeaders, "\n\n")
end

function LSPClient:notification(method, params)
    local msg = {
        jsonrpc = "2.0",
        method = method
    }
    if params ~= nil then
        msg.params = params
    else
        -- the spec allows params to be omitted but language server implementations
        -- are buggy so we can put an empty object there for now
        -- https://github.com/golang/go/issues/57459
        msg.params = json.object
    end
    self:send(msg)
end

function LSPClient:request(method, params)
    local msg = {
        jsonrpc = "2.0",
        id = self.requestId,
        method = method
    }
    if params ~= nil then
        msg.params = params
    else
        -- the spec allows params to be omitted but language server implementations
        -- are buggy so we can put an empty object there for now
        -- https://github.com/golang/go/issues/57459
        msg.params = json.object
    end
    self.sentRequests[self.requestId] = method
    self.requestId = self.requestId + 1
    self:send(msg)
end

function LSPClient:handleResponseError(method, error)
    display_error(string.format("%s (Error %d, %s)", error.message, error.code, method))

    if method == "textDocument/completion" then
        setCompletions({})
    end
end

function LSPClient:handleResponseResult(method, result)
    if method == "initialize" then
        self.serverCapabilities = result.capabilities
        if result.serverInfo then
            self.serverName = result.serverInfo.name
            self.serverVersion = result.serverInfo.version
            display_info(string.format("Initialized %s version %s", self.serverName, self.serverVersion))
        else
            display_info(string.format("Initialized '%s' (no version information)", self.clientId))
        end
        self:notification("initialized")
        activeConnections[self.clientId] = self
        allConnections[self.clientId] = nil
        if type(self.onInitialized) == "function" then
            self:onInitialized()
        end
        -- FIXME: iterate over *all* currently open buffers
        onBufferOpen(micro.CurPane().Buf)
    elseif method == "textDocument/hover" then
        local showHoverInfo = function (results)
            local bf = buffer.NewBuffer(results, "[µlsp] hover")
            bf.Type.Scratch = true
            bf.Type.Readonly = true
            micro.CurPane():HSplitIndex(bf, true)
        end

        -- result.contents being a string or array is deprecated but as of 2023
        -- * pylsp still responds with {"contents": ""} for no results
        -- * lua-lsp still responds with {"contents": []} for no results
        if result == nil or result.contents == "" or table.empty(result.contents) then
            display_info("No hover results")
        elseif type(result.contents) == "string" then
            showHoverInfo(result.contents)
        elseif type(result.contents.value) == "string" then
            showHoverInfo(result.contents.value)
        else
            display_info("WARNING: Ignored textDocument/hover result due to unrecognized format")
        end
    elseif method == "textDocument/formatting" then
        if result == nil or next(result) == nil then
            display_info("Formatted file (no changes)")
        else
            local textedits = result
            editBuf(micro.CurPane().Buf, textedits)
            display_info("Formatted file")
        end
    elseif method == "textDocument/rangeFormatting" then
        if result == nil or next(result) == nil then
            display_info("Formatted selection (no changes)")
        else
            local textedits = result
            editBuf(micro.CurPane().Buf, textedits)
            display_info("Formatted selection")
        end
    elseif method == "textDocument/completion" then
        -- TODO: handle result.isIncomplete = true somehow
        local completions = {}

        if result ~= nil then
            -- result can be either CompletionItem[] or an object
            -- { isIncomplete: bool, items: CompletionItem[] }
            completions = result.items or result
        end

        if #completions == 0 then
            display_info("No completions")
            setCompletions({})
            return
        end

        local cursor = micro.CurPane().Buf:GetActiveCursor()
        local backward = cursor.X
        while backward > 0 and util.IsWordChar(util.RuneStr(cursor:RuneUnder(backward-1))) do
            backward = backward - 1
        end

        cursor:SetSelectionStart(buffer.Loc(backward, cursor.Y))
        cursor:SetSelectionEnd(buffer.Loc(cursor.X, cursor.Y))

        local completionList = {}

        if self.serverName == "rust-analyzer" then
            -- unlike any other language server I've tried, rust-analyzer gives
            -- completions that don't start with current stem, end with special
            -- characters (eg. self::) and also occasionally contain duplicates
            -- (same identifier from different namespace)

            local stem = cursor:GetSelection()
            stem = util.String(stem)

            local uniqueCompletions = {}
            for _, completionItem in pairs(completions) do
                local item = completionItem.insertText or completionItem.label
                -- FIXME: micro's autocomplete doesn't deal well with non-alnum
                -- completions so we are currently just discarding them
                if item:match("^[%a%d_]+$") and item:startsWith(stem) then
                    uniqueCompletions[item] = 1
                end
            end

            for c, _ in pairs(uniqueCompletions) do
                table.insert(completionList, c)
            end
        else
            for _, completionItem in pairs(completions) do
                local item = completionItem.insertText or completionItem.label
                table.insert(completionList, item)
            end
        end

        cursor:DeleteSelection()
        setCompletions(completionList)

    elseif method == "textDocument/references" then
        if result == nil or table.empty(result) then
            display_info("No references found")
            return
        end
        showReferenceLocations("[µlsp] references", result)
    elseif
        method == "textDocument/declaration" or
        method == "textDocument/definition" or
        method == "textDocument/typeDefinition" or
        method == "textDocument/implementation"
    then
        -- result: Location | Location[] | LocationLink[] | null
        if result == nil or table.empty(result) then
            display_info(string.format("%s not found", method:match("textDocument/(.*)$")))
        else
            -- FIXME: handle list of results properly
            -- if result is a list just take the first one
            if result[1] then result = result[1] end

            -- FIXME: support LocationLink[]
            if result.targetRange ~= nil then
                display_info("LocationLinks are not supported yet")
                return
            end

            -- now result should be Location
            local filepath = absPathFromFileUri(result.uri)
            local startLoc, _ = LSPRange.toLocs(result.range)

            openFileAtLoc(filepath, startLoc)
        end
    elseif method == "textDocument/documentSymbol" then
        if result == nil or table.empty(result) then
            display_info("No symbols found in current document")
            return
        end
        local symbolLocations = {}
        local symbolLabels = {}
        local SYMBOLKINDS = {
	        [1] = "File",
	        [2] = "Module",
	        [3] = "Namespace",
	        [4] = "Package",
	        [5] = "Class",
	        [6] = "Method",
	        [7] = "Property",
	        [8] = "Field",
	        [9] = "Constructor",
	        [10] = "Enum",
	        [11] = "Interface",
	        [12] = "Function",
	        [13] = "Variable",
	        [14] = "Constant",
	        [15] = "String",
	        [16] = "Number",
	        [17] = "Boolean",
	        [18] = "Array",
	        [19] = "Object",
	        [20] = "Key",
	        [21] = "Null",
	        [22] = "EnumMember",
	        [23] = "Struct",
	        [24] = "Event",
	        [25] = "Operator",
	        [26] = "TypeParameter",
        }
        for _, sym in ipairs(result) do
            -- if sym.location is missing we are dealing with DocumentSymbol[]
            -- instead of SymbolInformation[]
            if sym.location == nil then
                table.insert(symbolLocations, {
                    uri = micro.CurPane().Buf.AbsPath,
                    range = sym.range
                })
            else
                table.insert(symbolLocations, sym.location)
            end
            table.insert(symbolLabels, string.format("%-15s %s", "["..SYMBOLKINDS[sym.kind].."]", sym.name))
        end
        showSymbolLocations("[µlsp] document symbols", symbolLocations, symbolLabels)
    else
        log("WARNING: dunno what to do with response to", method)
    end
end

function LSPClient:handleNotification(notification)
    if notification.method == "textDocument/publishDiagnostics" then
        local filePath = absPathFromFileUri(notification.params.uri)

        if self.openFiles[filePath] == nil then
            log("DEBUG: received diagnostics for document that is not open:", filePath)
            return
        end

        local docVersion = notification.params.version
        if docVersion ~= nil and docVersion ~= self.openFiles[filePath].version then
            log("WARNING: received diagnostics for outdated version of document")
            return
        end

        self.openFiles[filePath].diagnostics = notification.params.diagnostics

        -- in the usual case there is only one buffer with the same document so a loop
        -- would not be necessary, but there may sometimes be multiple buffers with the
        -- same exact document open!
        for _, buf in ipairs(docBuffers[filePath]) do
            showDiagnostics(buf, self.clientId, notification.params.diagnostics)
        end
    elseif notification.method == "window/showMessage" then
        -- notification.params.type can be 1 = error, 2 = warning, 3 = info, 4 = log, 5 = debug
        if notification.params.type < 3 then
            display_info(notification.params.message)
        end
    elseif notification.method == "window/logMessage" then
        -- TODO: somehow include these messages in `lsp-showlog`
    else
        log("WARNING: don't know what to do with that message")
    end
end

function LSPClient:receiveMessage(text)
    local decodedMsg = json.decode(text)
    local request = self.sentRequests[decodedMsg.id]
    if request then
        self.sentRequests[decodedMsg.id] = nil
        if decodedMsg.error then
            self:handleResponseError(request, decodedMsg.error)
        else
            self:handleResponseResult(request, decodedMsg.result)
        end
    else
        self:handleNotification(decodedMsg)
    end
end

function LSPClient:textDocumentIdentifier(buf)
    return { uri = string.format("file://%s", buf.AbsPath:uriEncode()) }
end

function LSPClient:didOpen(buf)
    local textDocument = self:textDocumentIdentifier(buf)
    local filePath = buf.AbsPath

    -- if file is already open, do nothing
    if self.openFiles[filePath] ~= nil then
        return
    end

    local bufText = util.String(buf:Bytes())
    self.openFiles[filePath] = {
        version = 1,
        diagnostics = {}
    }
    textDocument.languageId = buf:FileType()
    textDocument.version = 1
    textDocument.text = bufText

    self:notification("textDocument/didOpen", {
        textDocument = textDocument
    })
end

function LSPClient:didClose(buf)
    local textDocument = self:textDocumentIdentifier(buf)
    local filePath = buf.AbsPath

    if self.openFiles[filePath] ~= nil then
        self.openFiles[filePath] = nil

        self:notification("textDocument/didClose", {
            textDocument = textDocument
        })
    end
end

function LSPClient:didChange(buf, changes)
    local textDocument = self:textDocumentIdentifier(buf)
    local filePath = buf.AbsPath

    if self.openFiles[filePath] == nil then
        log("ERROR: tried to emit didChange event for document that was not open")
        return
    end

    local newVersion = self.openFiles[filePath].version + 1

    self.openFiles[filePath].version = newVersion
    textDocument.version = newVersion

    self:notification("textDocument/didChange", {
        textDocument = textDocument,
        contentChanges = changes
    })
end

function LSPClient:didSave(buf)
    local textDocument = self:textDocumentIdentifier(buf)

    self:notification("textDocument/didSave", {
        textDocument = textDocument
    })
end

function LSPClient:onStdout(text)

    -- TODO: figure out if this is a performance bottleneck when receiving long
    -- messages (tens of thousands of bytes) – I suspect Go's buffers would be
    -- much faster than Lua string concatenation
    self.buffer = self.buffer .. text

    while true do
        if self.expectedLength == nil then
            -- receive headers
            -- TODO: figure out if it's necessary to handle the Content-Type header
            local a, b = self.buffer:find("\r\n\r\n")
            if a == nil then return end
            local headers = self.buffer:sub(0, a)
            local _, _, m = headers:find("Content%-Length: (%d+)")
            self.expectedLength = tonumber(m)
            self.buffer = self.buffer:sub(b+1)

        elseif self.buffer:len() < self.expectedLength then
            return

        else
            -- receive content
            self:receiveMessage(self.buffer:sub(0, self.expectedLength))
            self.buffer = self.buffer:sub(self.expectedLength + 1)
            self.expectedLength = nil
        end
    end
end

function log(...)
    micro.Log("[µlsp]", unpack(arg))
end

function display_error(...)
    micro.InfoBar():Error("[µlsp] ", unpack(arg))
end

function display_info(...)
    micro.InfoBar():Message("[µlsp] ", unpack(arg))
end



-- USER TRIGGERED ACTIONS
function hoverAction(bufpane)
    local client = findClientWithCapability("hoverProvider", "hover information")
    if client ~= nil then
        local buf = bufpane.Buf
        local cursor = buf:GetActiveCursor()
        client:request("textDocument/hover", {
            textDocument = client:textDocumentIdentifier(buf),
            position = { line = cursor.Y, character = cursor.X }
        })
    end
end

function formatAction(bufpane)
    local buf = bufpane.Buf
    local selectedRanges = {}

    for i = 1, #buf:GetCursors() do
        local cursor = buf:GetCursor(i - 1)
        if cursor:HasSelection() then
            table.insert(selectedRanges, LSPRange.fromSelection(cursor.CurSelection))
        end
    end

    if #selectedRanges > 1 then
        display_error("Formatting multiple selections is not supported yet")
        return
    end

    local formatOptions = {
        -- most servers completely ignore these values but tabSize and
        -- insertSpaces are required according to the specification
        -- https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#formattingOptions
        tabSize = buf.Settings["tabsize"],
        insertSpaces = buf.Settings["tabstospaces"],
        trimTrailingWhitespace = true,
        insertFinalNewline = true,
        trimFinalNewlines = true
    }

    if #selectedRanges == 0 then
        local client = findClientWithCapability("documentFormattingProvider", "formatting")
        if client ~= nil then
            client:request("textDocument/formatting", {
                textDocument = client:textDocumentIdentifier(buf),
                options = formatOptions
            })
        end
    else
        local client = findClientWithCapability("documentRangeFormattingProvider", "formatting selections")
        if client ~= nil then
            client:request("textDocument/rangeFormatting", {
                textDocument = client:textDocumentIdentifier(buf),
                range = selectedRanges[1],
                options = formatOptions
            })
        end
    end
end

function completionAction(bufpane)
    local client = findClientWithCapability("completionProvider", "completion")
    if client ~= nil then
        local buf = bufpane.Buf
        local cursor = buf:GetActiveCursor()
        client:request("textDocument/completion", {
            textDocument = client:textDocumentIdentifier(buf),
            position = { line = cursor.Y, character = cursor.X },
            context = {
                -- 1 = Invoked, 2 = TriggerCharacter, 3 = TriggerForIncompleteCompletions
                triggerKind = 1,
            }
        })
    end
end

function gotoAction(kind)
    local cap = string.format("%sProvider", kind)
    local requestMethod = string.format("textDocument/%s", kind)

    return function(bufpane)
        local client = findClientWithCapability(cap, requestMethod)
        if client ~= nil then
            local buf = bufpane.Buf
            local cursor = buf:GetActiveCursor()
            client:request(requestMethod, {
                textDocument = client:textDocumentIdentifier(buf),
                position = { line = cursor.Y, character = cursor.X }
            })
        end
    end
end

function findReferencesAction(bufpane)
    local client = findClientWithCapability("referencesProvider", "finding references")
    if client ~= nil then
        local buf = bufpane.Buf
        local cursor = buf:GetActiveCursor()
        client:request("textDocument/references", {
            textDocument = client:textDocumentIdentifier(buf),
            position = { line = cursor.Y, character = cursor.X },
            context = { includeDeclaration = true }
        })
    end
end

function documentSymbolsAction(bufpane)
    local client = findClientWithCapability("documentSymbolProvider", "document symbols")
    if client ~= nil then
        local buf = bufpane.Buf
        client:request("textDocument/documentSymbol", {
            textDocument = client:textDocumentIdentifier(buf)
        })
    end
end

function openDiagnosticBufferAction(bufpane)
    local buf = bufpane.Buf
    local cursor = buf:GetActiveCursor()
    local filePath = buf.AbsPath
    local found = false

    for _, client in pairs(activeConnections) do
        local diagnostics = client.openFiles[filePath].diagnostics
        for idx, diagnostic in pairs(diagnostics) do
            local startLoc, endLoc = LSPRange.toLocs(diagnostic.range)
            if cursor.Loc.Y == startLoc.Y then
                found = true
                local bufContents = string.format(
                    "%s %s\nhref: %s\nseverity: %s\n\n%s",
                    diagnostic.source or client.serverName or client.clientId,
                    diagnostic.code or "(no error code)",
                    diagnostic.codeDescription and diagnostic.codeDescription.href or "-",
                    diagnostic.severity and severityToString(diagnostic.severity) or "-",
                    diagnostic.message
                )
                local bufTitle = string.format("[µlsp] %s diagnostics #%d", client.clientId, idx)
                local newBuffer = buffer.NewBuffer(bufContents, bufTitle)
                newBuffer.Type.Readonly = true
                local height = bufpane:GetView().Height
                local newpane = micro.CurPane():HSplitBuf(newBuffer)
                if height > 16 then
                    bufpane:ResizePane(height - 8)
                end
            end
        end
    end
    if not found then
        display_info("Found no diagnostics on current line")
    end
end


-- EVENTS (LUA CALLBACKS)
-- https://github.com/zyedidia/micro/blob/master/runtime/help/plugins.md#lua-callbacks

function onStdout(text, userargs)
    local clientId = userargs[1]
    log("<-(", clientId, "[stdout] )", text, "\n\n")
    local client = allConnections[clientId]
    client:onStdout(text)
end

function onStderr(text, userargs)
    local clientId = userargs[1]
    -- log("<-(", clientId, "[stderr] )", text, "\n\n")
    local client = allConnections[clientId]
    client.stderr = client.stderr .. text
end

function onExit(text, userargs)
    local clientId = userargs[1]
    local client = allConnections[clientId]
    if client then
        local reasonMsg
        if client.job.Err ~= nil then -- LookPath error
            reasonMsg = string.format("%s exited (%s)", clientId, client.job.Err:Error())
        elseif client.job.ProcessState ~= nil then
            reasonMsg = string.format("%s exited (%s)", clientId, client.job.ProcessState:String())
        else
            reasonMsg = string.format("%s exited", clientId)
        end
        display_error(reasonMsg)
        log(reasonMsg)
    end
    activeConnections[clientId] = nil
    allConnections[clientId] = nil
end

function onBufferOpen(buf)
    if buf.Type.Kind ~= buffer.BTDefault then return end
    if buf:FileType() == "unknown" then return end
    -- Ignore buffers created by clients
    if string.startsWith(buf:GetName(), "[µlsp]") then return end

    local filePath = buf.AbsPath

    if docBuffers[filePath] == nil then
        docBuffers[filePath] = { buf }
    else
        table.insert(docBuffers[filePath], buf)
    end

    for _, client in pairs(activeConnections) do
        client:didOpen(buf)
    end

    local autostarts = settings.autostart[buf:FileType()]
    if autostarts ~= nil then
        for _, server in ipairs(autostarts) do
            local clientId = server.shortName or server.cmd
            if allConnections[clientId] == nil then
                LSPClient:initialize(server)
            end
        end
    end
end

function onQuit(bufpane)
    local closedBuf = bufpane.Buf
    if closedBuf.Type.Kind ~= buffer.BTDefault then return end

    local filePath = closedBuf.AbsPath
    if docBuffers[filePath] == nil then
        return
    elseif #docBuffers[filePath] > 1 then
        -- there are still other buffers with the same file open
        local remainingBuffers = {}
        for _, buf in ipairs(docBuffers[filePath]) do
            if buf ~= closedBuf then
                table.insert(remainingBuffers, buf)
            end
        end
        docBuffers[filePath] = remainingBuffers
    else
        -- this was the last buffer in which this particular file was open
        docBuffers[filePath] = nil

        for _, client in pairs(activeConnections) do
            client:didClose(closedBuf)
        end
    end

end

function onSave(bufpane)
    for _, client in pairs(activeConnections) do
        client:didSave(bufpane.Buf)
    end
end

function preAutocomplete(bufpane)
    -- use micro's own autocompleter if there is no LSP connection
    if next(activeConnections) == nil then return end
    if not settings.tabAutocomplete then return end
    if findClientWithCapability("completionProvider") == nil then return end

    -- "[µlsp] no autocompletions" message can be confusing if it does
    -- not get cleared before falling back to micro's own completion
    bufpane:ClearInfo()

    local cursor = bufpane.Buf:GetActiveCursor()

    -- don't autocomplete at the beginning of the line because you
    -- often want tab to mean indentation there!
    if cursor.X == 0 then return end

    -- if last auto completion happened on the same line then don't
    -- do completionAction again (because updating the completions
    -- would mess up tabbing through the suggestions)
    -- FIXME: invent a better heuristic than line number for this
    if lastAutocompletion == cursor.Y then return end

    local charBeforeCursor = util.RuneStr(cursor:RuneUnder(cursor.X-1))

    if charBeforeCursor:match("%S") then
        -- make sure there are at least two empty suggestions to capture
        -- the autocompletion event – otherwise micro inserts '\t' before
        -- the language server has a chance to reply with suggestions
        setCompletions({"", ""})

        completionAction(bufpane)
        lastAutocompletion = cursor.Y
    end
end

-- Prevent inserting tab when autocompletions are being requested
function preInsertTab(bufpane)
    if next(activeConnections) == nil then return true end
    if not settings.tabAutocomplete then return true end

    local cursor = bufpane.Buf:GetActiveCursor()
    return lastAutocompletion ~= cursor.Y
end

-- FIXME: figure out how to disable all this garbage when there are no active connections

function onBeforeTextEvent(buf, tevent)
    if next(activeConnections) == nil then return end

    local changes = {}
    for _, delta in userdataIterator(tevent.Deltas) do
        table.insert(
            changes,
            {
                range = LSPRange.fromDelta(delta),
                text = util.String(delta.Text)
            }
        )
    end

    for _, client in pairs(activeConnections) do
    	client:didChange(buf, changes)
    end
end

function syncFullDocument(buf)
    if next(activeConnections) == nil then return end

    clearAutocomplete()
    -- filetype is "unknown" for the command prompt
    if buf:FileType() == "unknown" then
        return
    end

    local changes = {
        { text = util.String(buf:Bytes()) }
    }
    for _, client in pairs(activeConnections) do
        client:didChange(buf, changes)
    end
end

function onCursorUp(bufpane)       clearAutocomplete() end
function onCursorDown(bufpane)     clearAutocomplete() end
function onCursorPageUp(bufpane)   clearAutocomplete() end
function onCursorPageDown(bufpane) clearAutocomplete() end
function onCursorLeft(bufpane)     clearAutocomplete() end
function onCursorRight(bufpane)    clearAutocomplete() end
function onCursorStart(bufpane)    clearAutocomplete() end
function onCursorEnd(bufpane)      clearAutocomplete() end

function preUndo(bp)
    undoStackLengthBefore = bp.Buf.UndoStack:Len()
end
function onUndo(bp)
    local numUndos = undoStackLengthBefore - bp.Buf.UndoStack:Len()
    return handleUndosRedos(bp.Buf, bp.Buf.RedoStack.Top, numUndos)
end

function preRedo(bp)
    undoStackLengthBefore = bp.Buf.UndoStack:Len()
end
function onRedo(bp)
    local numRedos = bp.Buf.UndoStack:Len() - undoStackLengthBefore
    return handleUndosRedos(bp.Buf, bp.Buf.UndoStack.Top, numRedos)
end

function handleUndosRedos(buf, elem, numChanges)
    if next(activeConnections) == nil then return end

    local TEXT_EVENT = {INSERT = 1, REMOVE = -1, REPLACE = 0}
    local tevents = {}
    for i = 1, numChanges do
        table.insert(tevents, elem.Value)
        elem = elem.Next
    end

    local changes = {}
    for i = 1, #tevents do
        local tev = tevents[#tevents + 1 - i]
        for _, delta in userdataIterator(tev.Deltas) do
            local text = ""
            local range = LSPRange.fromDelta(delta)

            if tev.EventType == TEXT_EVENT.INSERT then
                range["end"] = range["start"]
                text = util.String(delta.Text)
            end

            table.insert(changes, { range = range, text = text })
        end
    end

    for _, client in pairs(activeConnections) do
    	client:didChange(buf, changes)
    end
end

-- HELPER FUNCTIONS

function string.split(str)
    local result = {}
    for x in str:gmatch("[^%s]+") do
        table.insert(result, x)
    end
    return result
end

function string.startsWith(str, needle)
	return string.sub(str, 1, #needle) == needle
end

function string.uriDecode(str)
    local function hexToChar(x)
        return string.char(tonumber(x, 16))
    end
    return str:gsub("%%(%x%x)", hexToChar)
end

function string.uriEncode(str)
    local function charToHex(c)
        return string.format("%%%02X", string.byte(c))
    end
    str = str:gsub("([^%w/ _%-.~])", charToHex)
    str = str:gsub(" ", "+")
    return str
end


function table.empty(x)
    return type(x) == "table" and next(x) == nil
end


function editBuf(buf, textedits)
    -- sort edits by start position (earliest first)
    local function sortByRangeStart(texteditA, texteditB)
        local a = texteditA.range.start
        local b = texteditB.range.start
        return a.line < b.line or (a.line == b.line and a.character < b.character)
    end
    -- FIXME: table.sort is not guaranteed to be stable, and the LSP specification
    -- says that if two edits share the same start position the order in the array
    -- should dictate the order, so this is probably bugged in rare edge cases...
    table.sort(textedits, sortByRangeStart)

    local cursor = buf:GetActiveCursor()

    -- maybe there is a nice way to keep multicursors and selections? for now let's
    -- just get rid of them before editing the buffer to avoid weird behavior
    buf:ClearCursors()
    cursor:Deselect(true)

    -- using byte offset seems to be the easiest & most reliable way to keep cursor
    -- position even when lines get added/removed
    local cursorLoc = buffer.Loc(cursor.Loc.X, cursor.Loc.Y)
    local cursorByteOffset = buffer.ByteOffset(cursorLoc, buf)

    local editedBufParts = {}

    local prevEnd = buf:Start()

    for _, textedit in pairs(textedits) do
        local startLoc, endLoc = LSPRange.toLocs(textedit.range)
        if endLoc:GreaterThan(buf:End()) then
            endLoc = buf:End()
        end

        table.insert(editedBufParts, util.String(buf:Substr(prevEnd, startLoc)))
        table.insert(editedBufParts, textedit.newText)
        prevEnd = endLoc

        -- if the cursor is in the middle of a textedit this can move it a bit but it's fiiiine
        -- (I don't think there's a clean way to figure out the right place for it)
        if startLoc:LessThan(cursorLoc) then
            local oldTextLength = buffer.ByteOffset(endLoc, buf) - buffer.ByteOffset(startLoc, buf)
            cursorByteOffset = cursorByteOffset - oldTextLength + textedit.newText:len()
        end
    end

    table.insert(editedBufParts, util.String(buf:Substr(prevEnd, buf:End())))

    buf:Remove(buf:Start(), buf:End())
    buf:Insert(buf:End(), go_strings.Join(editedBufParts, ""))

    local newCursorLoc = buffer.Loc(0, 0):Move(cursorByteOffset, buf)
    buf:GetActiveCursor():GotoLoc(newCursorLoc)

    syncFullDocument(buf)
end

function severityToString(severity)
    local severityTable = {
        [1] = "error",
        [2] = "warning",
        [3] = "information",
        [4] = "hint"
    }
    return severityTable[severity] or "information"
end

function showDiagnostics(buf, owner, diagnostics)

    buf:ClearMessages(owner)

    for _, diagnostic in pairs(diagnostics) do
        local severity = severityToString(diagnostic.severity)

        if settings.showDiagnostics[severity] then
            local extraInfo = nil
            if diagnostic.code ~= nil then
                diagnostic.code = tostring(diagnostic.code)
                if string.startsWith(diagnostic.message, diagnostic.code .. " ") then
                    diagnostic.message = diagnostic.message:sub(2 + #diagnostic.code)
                end
            end
            if diagnostic.source ~= nil and diagnostic.code ~= nil then
                extraInfo = string.format("(%s %s) ", diagnostic.source, diagnostic.code)
            elseif diagnostic.source ~= nil then
                extraInfo = string.format("(%s) ", diagnostic.source)
            elseif diagnostic.code ~= nil then
                extraInfo = string.format("(%s) ", diagnostic.code)
            end

            local lineNumber = diagnostic.range.start.line + 1

            local msgType = buffer.MTInfo
            if severity == "warning" then
                msgType = buffer.MTWarning
            elseif severity == "error" then
                msgType = buffer.MTError
            end

            local startLoc, endLoc = LSPRange.toLocs(diagnostic.range)

            -- prevent underlining empty space at the ends of lines
            -- (fix pylsp being off-by-one with endLoc.X)
            local endLineLength = #buf:Line(endLoc.Y)
            if endLoc.X > endLineLength then
                endLoc = buffer.Loc(endLineLength, endLoc.Y)
            end

            local msg = diagnostic.message
            -- make the msg look better on one line if there's newlines or extra whitespace
            msg = msg:gsub("(%a)\n(%a)", "%1 / %2"):gsub("%s+", " ")
            msg = string.format("[µlsp] %s%s", extraInfo or "", msg)
            buf:AddMessage(buffer.NewMessage(owner, msg, startLoc, endLoc, msgType))
        end
    end
end

function clearAutocomplete()
    lastAutocompletion = -1
end

function setCompletions(completions)
    local buf = micro.CurPane().Buf

    buf.Suggestions = completions
    buf.Completions = completions
    buf.CurSuggestion = -1

    if next(completions) == nil then
        buf.HasSuggestions = false
    else
        buf:CycleAutocomplete(true)
    end
end

function findClientWithCapability(capabilityName, featureDescription)
    if next(activeConnections) == nil then
        display_error("No language server is running! Try starting one with the `lsp` command.")
        return
    end

    for _, client in pairs(activeConnections) do
        if client.serverCapabilities[capabilityName] then
            return client
        end
    end
    if featureDescription ~= nil then
        display_error("None of the active language server(s) support ", featureDescription)
    end
    return nil
end

function absPathFromFileUri(uri)
    local match = uri:match("file://(.*)$")
    if match then
        return match:uriDecode()
    else
        return uri
    end
end

function relPathFromAbsPath(absPath)
    local cwd, err = go_os.Getwd()
    if err then return absPath end
    local relPath
    relPath, err = filepath.Rel(cwd, absPath)
    if err then return absPath end
    return relPath
end

function openFileAtLoc(filepath, loc)
    local bp = micro.CurPane()

    -- don't open a new tab if file is already open
    local alreadyOpenPane, tabIdx, paneIdx = findBufPaneByPath(filepath)

    if alreadyOpenPane then
        micro.Tabs():SetActive(tabIdx)
        alreadyOpenPane:tab():SetActive(paneIdx)
        bp = alreadyOpenPane
    else
        local newBuf, err = buffer.NewBufferFromFile(filepath)
        if err ~= nil then
            display_error(err)
            return
        end
        bp:AddTab()
        bp = micro.CurPane()
        bp:OpenBuffer(newBuf)
    end

    bp.Buf:ClearCursors() -- remove multicursors
    local cursor = bp.Buf:GetActiveCursor()
    cursor:Deselect(false) -- clear selection
    cursor:GotoLoc(loc)
    bp.Buf:RelocateCursors() -- make sure cursor is inside the buffer
    bp:Center()
end

-- takes Location[] https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location
-- and renders them to user
function showSymbolLocations(newBufferTitle, lspLocations, labels)
    local symbols = {}
    local maxLabelLen = 0
    for i, lspLoc in ipairs(lspLocations) do
        local fpath = absPathFromFileUri(lspLoc.uri)
        local lineNumber = lspLoc.range.start.line + 1
        local columnNumber = lspLoc.range.start.character + 1
        local labelLen = #labels[i]
        symbols[i] = {
            label = labels[i],
            location = string.format("%s:%d:%d\n", fpath, lineNumber, columnNumber)
        }
        if maxLabelLen < labelLen then maxLabelLen = labelLen end
    end

    local bufContents = ""
    local format = "%-" .. maxLabelLen .. "s # %s"
    for _, sym in ipairs(symbols) do
        bufContents = bufContents .. string.format(format, sym.label, sym.location)
    end

    local newBuffer = buffer.NewBuffer(bufContents, newBufferTitle)
    newBuffer.Type.Scratch = true
    newBuffer.Type.Readonly = true
    micro.CurPane():HSplitBuf(newBuffer)
end

-- takes Location[] https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location
-- and renders them to user
function showReferenceLocations(newBufferTitle, lspLocations)
    local references = {}
    for i, lspLoc in ipairs(lspLocations) do
        local fpath = absPathFromFileUri(lspLoc.uri)
        local lineNumber = lspLoc.range.start.line + 1
        local columnNumber = lspLoc.range.start.character + 1
        references[i] = {
            path = fpath,
            line = lineNumber,
            column = columnNumber,
        }
    end

    table.sort(references, function(a, b)
        if a.path ~= b.path then return a.path < b.path end
        if a.line ~= b.line then return a.line < b.line end
        return a.column < b.column
    end)

    local bufLines = {}
    local curFilePath = ""
    local file = nil
    local lineCount = 0
    local lineContent = ""
    for _, ref in ipairs(references) do
        if curFilePath ~= ref.path then
            if file then file:close() end
            if #bufLines > 0 then table.insert(bufLines, "") end
            curFilePath = ref.path
            table.insert(bufLines, curFilePath)
            file = io.open(curFilePath, "rb")
            lineCount = 0
        end

        -- file can be nil if io.open failed
        if file ~= nil then
            while lineCount < ref.line do
                lineContent = file:read("*l")
                lineCount = lineCount + 1
            end
        end
        table.insert(bufLines, string.format("\t%d:%d:%s", ref.line, ref.column, lineContent or ""))
    end

    if file then file:close() end -- last iteration does not close last file
    table.insert(bufLines, "")

    local newBuffer = buffer.NewBuffer(table.concat(bufLines, "\n"), newBufferTitle)
    newBuffer.Type.Scratch = true
    newBuffer.Type.Readonly = true
    --We enforce tabs, dont annoy users
    newBuffer.Settings["hltaberrors"] = false
    micro.CurPane():HSplitBuf(newBuffer)
end

function findBufPaneByPath(fpath)
    if fpath == nil then return nil end
    for tabIdx, tab in userdataIterator(micro.Tabs().List) do
        for paneIdx, pane in userdataIterator(tab.Panes) do
            -- pane.Buf is nil for panes that are not BufPanes (terminals etc)
            if pane.Buf ~= nil and fpath == pane.Buf.AbsPath then
                -- lua indexing starts from 1 but go is stupid and starts from 0 :/
                return pane, tabIdx - 1, paneIdx - 1
            end
        end
    end
end

function userdataIterator(data)
    local idx = 0
    return function ()
        idx = idx + 1
        local success, item = pcall(function() return data[idx] end)
        if success then return idx, item end
    end
end
