-- SPDX-FileCopyrightText: 2024 blinry <mail@blinry.org>
-- SPDX-FileCopyrightText: 2024 zormit <nt4u@kpvn.de>
-- SPDX-FileCopyrightText: 2025 mbitard <code@bitard.fr>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later

local M = {}

-- This variable is a table that maps user IDs to a list of cursors.
-- Each cursor has an URI, an LSP range, an optional name,
-- and, if we already created it, an optional extmark information,
-- (including the buffer number and the extmark id).
-- { user_id => {
--      name: string,
--      cursors: { uri, range, extmark: { bufnr: int, id: int}?}}
--    }
-- }
local user_cursors = {}
local cursor_namespace = vim.api.nvim_create_namespace("Ethersync")
local offset_encoding = "utf-32"
local cursor_timeout_ms = 300 * 1000
local following_user_id = nil

local function show_cursor_information(name, cursor)
    return name .. " @ " .. vim.uri_to_fname(cursor.uri) .. ":" .. cursor.range.start.line + 1
end

-- https://www.reddit.com/r/neovim/comments/152bs5t/unable_to_render_comments_in_the_color_id_like/
vim.api.nvim_create_autocmd("ColorScheme", {
    pattern = "*",
    callback = function()
        vim.api.nvim_set_hl(0, "EthersyncUsername", { fg = "#808080", ctermfg = 12 })
    end,
})
vim.api.nvim_exec_autocmds("ColorScheme", {})

local function is_forward(start_row, end_row, start_col, end_col)
    return (start_row < end_row) or (start_row == end_row and start_col <= end_col)
end

local function jump_to_user_id(user_id)
    local cursors = user_cursors[user_id].cursors
    local cursor = cursors[#cursors]
    if cursor == nil then
        following_user_id = nil
    else
        local location = {
            targetUri = cursor.uri,
            targetRange = cursor.range,
            targetSelectionRange = cursor.range,
        }
        -- In Neovim 0.11, jump_to_location was deprecated.
        if vim.version().api_level < 13 then
            vim.lsp.util.jump_to_location(location, offset_encoding, true)
        else
            vim.lsp.util.show_document(location, offset_encoding)
        end
    end
end

-- A new set of ranges means, we delete all existing ones for that user.
function M.set_cursor(uri, user_id, name, ranges)
    -- Find correct buffer to apply edits to.
    local bufnr = vim.uri_to_bufnr(uri)

    if user_cursors[user_id] then
        for _, user_cursor in ipairs(user_cursors[user_id].cursors) do
            if user_cursor.extmark then
                local old_id = user_cursor.extmark.id
                local old_bufnr = user_cursor.extmark.bufnr
                vim.api.nvim_buf_del_extmark(old_bufnr, cursor_namespace, old_id)
            end
        end
    end
    user_cursors[user_id] = { name = name, cursors = {} }

    if not vim.api.nvim_buf_is_loaded(bufnr) then
        -- TODO: Should we also implement a timeout here?
        for _, range in ipairs(ranges) do
            table.insert(user_cursors[user_id].cursors, { uri = uri, range = range, extmark = nil })
        end
    else
        for i, range in ipairs(ranges) do
            -- Convert from LSP style ranges to Neovim style ranges.
            local start_row = range.start.line
            local start_col = vim.lsp.util._get_line_byte_from_position(bufnr, range.start, offset_encoding)
            local end_row = range["end"].line
            local end_col = vim.lsp.util._get_line_byte_from_position(bufnr, range["end"], offset_encoding)

            -- If the range is backwards, swap the start and end positions.
            if not is_forward(start_row, end_row, start_col, end_col) then
                start_row, end_row = end_row, start_row
                start_col, end_col = end_col, start_col
            end

            -- If the range is empty, expand the highlighted range by 1 to make it visible.
            if start_row == end_row and start_col == end_col then
                local bytes_in_end_row = vim.fn.strlen(vim.fn.getline(end_row + 1))
                if bytes_in_end_row > end_col then
                    -- Note: Instead of 1, we should actually add the byte length of the character at this position.
                    end_col = end_col + 1
                elseif bytes_in_end_row > 0 then
                    -- This highlights the last character in the row.
                    start_col = start_col - 1
                end
            end

            local e = {
                start_row = start_row,
                start_col = start_col,
                end_row = end_row,
                end_col = end_col,
            }

            local virt_text = {}
            if i == 1 and name then
                virt_text = { { name, "EthersyncUsername" } }
            end

            -- Try setting the extmark, ignore errors (which can happen at end of lines/buffers).
            pcall(function()
                local extmark_id = vim.api.nvim_buf_set_extmark(bufnr, cursor_namespace, e.start_row, e.start_col, {
                    hl_mode = "combine",
                    hl_group = "TermCursor",
                    end_col = e.end_col,
                    end_row = e.end_row,
                    virt_text = virt_text,
                })
                vim.defer_fn(function()
                    vim.api.nvim_buf_del_extmark(bufnr, cursor_namespace, extmark_id)
                    for _, user_cursor in ipairs(user_cursors[user_id].cursors) do
                        if user_cursor.extmark ~= nil and user_cursor.extmark.id == extmark_id then
                            -- If we find our own extmark_id, we can remove all ranges,
                            -- because they were all created at the same time.
                            user_cursors[user_id].cursors = {}
                            break
                        end
                    end
                end, cursor_timeout_ms)

                table.insert(
                    user_cursors[user_id].cursors,
                    { uri = uri, range = range, extmark = { id = extmark_id, bufnr = bufnr } }
                )
            end)
        end
    end
    if following_user_id == user_id then
        jump_to_user_id(user_id)
    end
end

function M.track_cursor(bufnr, callback)
    vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "ModeChanged" }, {
        buffer = bufnr,
        callback = function()
            local ranges = {}

            -- TODO: Split this code into multiple functions.
            local visualSelection = vim.fn.mode() == "v" or vim.fn.mode() == "V" or vim.fn.mode() == ""
            if visualSelection then
                -- This is the "active end" that the protocol talks about.
                local end_row, end_col = unpack(vim.api.nvim_win_get_cursor(0))
                -- Whereas this corresponds the the "anchor" in other range data structures.
                local _, start_row, start_col = unpack(vim.fn.getpos("v"))

                -- When using getpos(), the column is 1-indexed, but we want it to be 0-indexed.
                start_col = start_col - 1

                if vim.fn.mode() == "v" then
                    -- TODO: This is not in "screen columns", but in "bytes"!
                    if not is_forward(start_row, end_row, start_col, end_col) then
                        -- If the selection is backwards, we need to extend it on both ends for some reason.
                        end_col = end_col - 1
                        start_col = start_col + 1
                    end
                elseif vim.fn.mode() == "V" then
                    -- If we're in linewise visual mode, expand the range to include the entire line(s).
                    if is_forward(start_row, end_row, start_col, end_col) then
                        start_col = 0
                        end_col = vim.fn.strlen(vim.fn.getline(end_row)) - 1
                    else
                        start_col = vim.fn.strlen(vim.fn.getline(start_row))
                        end_col = -1
                    end
                end

                if vim.fn.mode() == "v" or vim.fn.mode() == "V" then
                    local range = vim.lsp.util.make_given_range_params(
                        { start_row, start_col },
                        { end_row, end_col },
                        bufnr,
                        offset_encoding
                    ).range
                    ranges = { range }
                elseif vim.fn.mode() == "" then
                    -- We are in blockwise visual mode. Calculate the individual pieces.

                    -- This calculation is a bit more involved, because Neovim forms a blockwise range visually, going by the
                    -- "display cells", so that the range is always rectangular. We need to perform our own calculations with
                    -- these display cells to make sure that we send out the same ranges.

                    -- TODO: There are still some inconsistencies; when the cursor is inside a multi-column character, other lines might be too short.

                    -- At this point, start_col and end_col are zero-indexed, in bytes, and related to the position in front of the cursor.

                    local start_line = vim.fn.getline(start_row)
                    local end_line = vim.fn.getline(end_row)

                    local string_to_start = string.sub(start_line, 0, start_col)
                    local string_to_end = string.sub(end_line, 0, end_col)

                    -- These are the widths of the strings in "display cells".
                    local cells_to_start = vim.fn.strdisplaywidth(string_to_start)
                    local cells_to_end = vim.fn.strdisplaywidth(string_to_end)

                    local smaller_cell = math.min(cells_to_start, cells_to_end)
                    local larger_cell = math.max(cells_to_start, cells_to_end)

                    local smaller_row = math.min(start_row, end_row)
                    local larger_row = math.max(start_row, end_row)

                    for row = smaller_row, larger_row do
                        local bytes_start = vim.fn.virtcol2col(bufnr, row, smaller_cell + 1) - 1
                        local bytes_end = vim.fn.virtcol2col(bufnr, row, larger_cell + 1) - 1

                        if bytes_start ~= -1 then
                            -- The line is not empty, add a range.
                            local range = vim.lsp.util.make_given_range_params(
                                { row, bytes_start },
                                { row, bytes_end },
                                bufnr,
                                offset_encoding
                            ).range
                            table.insert(ranges, range)
                        end
                    end
                end
            else
                local range = vim.lsp.util.make_range_params(0, offset_encoding).range
                ranges = { range }
            end

            callback(ranges)
        end,
    })
    -- Trigger the callback above once, to send initial cursor position.
    vim.api.nvim_exec_autocmds("CursorMoved", {})
end

function M.unfollow_cursor()
    if following_user_id ~= nil then
        print("Unfollowed cursor")
        following_user_id = nil
    end
end

local function pick_cursor_menu(callback)
    local users = {}
    local descriptions = {}
    local max_width = 10
    local locations = {}
    for user_id, data in pairs(user_cursors) do
        local _, cursor = next(data.cursors)
        if cursor then
            local description = show_cursor_information(data.name, cursor)
            local desc_width = vim.fn.strdisplaywidth(description)
            if desc_width > max_width then
                max_width = desc_width
            end

            table.insert(descriptions, description)

            table.insert(users, user_id)
            table.insert(locations, {
                targetUri = cursor.uri,
                targetRange = cursor.range,
                targetSelectionRange = cursor.range,
            })
        end
    end

    if #users == 0 then
        print("No cursor available.")
    elseif #users == 1 then
        callback(users[1])
    else
        local buf = vim.api.nvim_create_buf(false, true)
        vim.api.nvim_buf_set_lines(buf, 0, -1, true, descriptions)
        vim.bo[buf].modifiable = false

        local opts = {
            relative = "cursor",
            width = max_width,
            height = #descriptions,
            col = 0,
            row = 1,
            anchor = "NW",
            style = "minimal",
            border = "single",
        }
        local win = vim.api.nvim_open_win(buf, true, opts)

        vim.api.nvim_buf_set_keymap(buf, "n", "<CR>", "", {
            callback = function()
                local line_number = vim.fn.line(".")
                vim.api.nvim_win_close(win, true)
                callback(users[line_number])
            end,
        })
        vim.api.nvim_buf_set_keymap(buf, "n", "<Esc>", "", {
            callback = function()
                vim.api.nvim_win_close(win, true)
            end,
        })
        vim.api.nvim_create_autocmd("BufLeave", {
            buffer = buf,
            callback = function()
                vim.api.nvim_win_close(win, true)
            end,
        })
    end
end

function M.follow_cursor()
    local callback = function(user_id)
        print("Following cursor")
        following_user_id = user_id
        jump_to_user_id(following_user_id)
    end
    pick_cursor_menu(callback)
end

function M.jump_to_cursor()
    local callback = function(user_id)
        jump_to_user_id(user_id)
    end
    pick_cursor_menu(callback)
end

function M.list_cursors()
    local message = ""

    if next(user_cursors) == nil then
        message = "No cursors."
    else
        for _, data in pairs(user_cursors) do
            local name = data.name
            local cursors = data.cursors
            message = message .. (name or "") .. ":"
            if #cursors == 0 then
                message = message .. " No cursors"
            elseif #cursors == 1 then
                message = show_cursor_information(name, cursors[1])
            else
                message = message .. " Multiple cursors in " .. vim.uri_to_fname(cursors[1].uri)
            end
        end
    end

    return message
end

return M
