local lsp_methods = require("vim.lsp.protocol").Methods

local M = {}

local extmark_ns = vim.api.nvim_create_namespace("user.util.input.extmark")
local win_hl_ns = vim.api.nvim_create_namespace("user.util.input.win_hl")
local buf_hl_ns = vim.api.nvim_create_namespace("user.util.input.buf_hl")

---@class Config
---@field prepare_rename boolean
---@field request_timeout integer
---@field show_other_ocurrences boolean
---@field use_patterns boolean
---@field keys KeysConfig
---@field hl HlConfig

---@class KeysConfig
---@field submit {[1]: string, [2]: string}[]
---@field cancel {[1]: string, [2]: string}[]
---
---@class HlConfig
---@field current string
---@field others string

---@class live_rename.UserConfig
---@field prepare_rename boolean?
---@field request_timeout integer?
---@field show_other_ocurrences boolean?
---@field use_patterns boolean?
---@field keys live_rename.UserKeysConfig?
---@field hl live_rename.UserHlConfig?

---@class live_rename.UserKeysConfig
---@field submit {[1]: string, [2]: string}[]?
---@field cancel {[1]: string, [2]: string}[]?
---
---@class live_rename.UserHlConfig
---@field current string?
---@field others string?

---@type Config
local cfg = {
    -- Send a `textDocument/prepareRename` request to the server to
    -- determine the word to be renamed, can be slow on some servers.
    -- Otherwise fallback to using `<cword>`.
    prepare_rename = true,
    --- The timeout for the `textDocument/prepareRename` request and final
    --- `textDocument/rename` request when submitting.
    request_timeout = 1500,
    -- Make an initial `textDocument/rename` request to gather other
    -- occurences which are edited and use these ranges to preview.
    -- If disabled only the word under the cursor will have a preview.
    show_other_ocurrences = true,
    -- Try to infer patterns from the initial `textDocument/rename` request
    -- and use these to show hopefully better edit previews.
    use_patterns = true,
    -- The register which is used to temporarily record a macro into. This
    -- macro can then be executed on other symbols using the `macrorepeat`
    -- rename option.
    scratch_register = "l",
    keys = {
        submit = {
            { "n", "<cr>" },
            { "v", "<cr>" },
            { "i", "<cr>" },
        },
        cancel = {
            { "n", "<esc>" },
            { "n", "q" },
        },
    },
    hl = {
        current = "CurSearch",
        others = "Search",
    },
}

---@class ClientWrapper
---@field client table<any,any>
local ClientWrapper = {}

function ClientWrapper:supports_method(...)
    return self.client.supports_method(...)
end

function ClientWrapper:request(...)
    return self.client.request(...)
end

function ClientWrapper:cancel_request(...)
    return self.client.cancel_request(...)
end

function ClientWrapper:__index(key)
    return ClientWrapper[key] or self.client[key]
end

function ClientWrapper:__newindex(key, val)
    self.client[key] = val
end

---@param client vim.lsp.Client
---@return vim.lsp.Client
local function wrap_client(client)
    if vim.fn.has("nvim-0.11") == 1 then
        return client
    end

    return setmetatable({ client = client }, ClientWrapper)
end

---@class Context
---@field doc_buf integer
---@field doc_win integer
---@field float_win integer
---@field float_buf integer
---@field cword CursorWord
---@field new_text string
---@field extmark_id integer
---@field opts RenameOpts
---
---@field client vim.lsp.Client
---@field prev_conceallevel integer
---@field ref_transaction_id number
---@field editing_ranges EditingRange[]?
---@field rename_params lsp.RenameParams

---@class CursorWord
---@field text string
---@field line integer
---@field start_col integer
---@field end_col integer
-- The initial offset inside the word, when the rename was started.
---@field initial_offset integer

---@class EditingRange
---@field extmark_id integer
---@field line integer
---@field start_col integer
---@field end_col integer
---@field pattern string?

--- session context
---@type Context?
local C = nil

---@param user_cfg live_rename.UserConfig?
function M.setup(user_cfg)
    cfg = vim.tbl_deep_extend("force", cfg, user_cfg or {})
end

---@param opts RenameOpts?
---@return fun()
function M.map(opts)
    return function()
        M.rename(opts)
    end
end

---@param message string
---@param err any?
local function notify_error(message, err)
    if err then
        ---@type string
        local err_msg
        if type(err) == "string" then
            err_msg = err
        elseif type(err) == "table" and err.message and type(err.message) == "string" then
            ---@diagnostic disable-next-line: no-unknown
            err_msg = err.message
        else
            err_msg = vim.inspect(err)
        end
        message = string.format("%s: `%s`", message, err_msg)
    end
    vim.notify(message, vim.log.levels.ERROR, { title = "live-rename" })
end

---@param client vim.lsp.Client
---@param buf integer
---@param range lsp.Range
---@return integer, integer
local function range_to_cols(client, buf, range)
    local start_col = vim.lsp.util._get_line_byte_from_position(buf, range.start, client.offset_encoding)
    local end_col = vim.lsp.util._get_line_byte_from_position(buf, range["end"], client.offset_encoding)
    return start_col, end_col
end

--- slightly modified from `vim.lsp.client.lua`
---@param client vim.lsp.Client
---@param method string
---@param params lsp.TextDocumentPositionParams|lsp.RenameParams
---@param bufnr integer
---@return table<string,any>?
local function lsp_request_sync(client, method, params, bufnr)
    local request_result = nil
    local function sync_handler(err, result, context, config)
        request_result = {
            err = err,
            result = result,
            context = context,
            config = config
        }
    end

    local success, request_id = client:request(method, params, sync_handler, bufnr)
    if not success then
        return nil
    end

    local wait_result = vim.wait(cfg.request_timeout, function()
        return request_result ~= nil
    end, 5)

    if not wait_result then
        if request_id then
            client:cancel_request(request_id)
        end
        return nil
    end
    return request_result
end

---@param transaction_id number
---@param unique_name string
---@return lsp.Handler
local function rename_refs_handler(transaction_id, unique_name)
    ---@param err lsp.ResponseError?
    ---@param result lsp.WorkspaceEdit?
    return function(err, result)
        -- check if the user is still in the same renaming session
        if C == nil or C.ref_transaction_id ~= transaction_id then
            return
        end

        if result == nil or err ~= nil then
            notify_error("[LSP] rename, error getting references", err)
            return
        end

        ---@type lsp.TextEdit[]?
        local document_edits = nil
        if result.changes then
            for uri, edits in pairs(result.changes) do
                if vim.uri_to_bufnr(uri) == C.doc_buf then
                    document_edits = edits
                    break
                end
            end
        elseif result.documentChanges then
            for _, change in ipairs(result.documentChanges) do
                if change.edits and change.textDocument and vim.uri_to_bufnr(change.textDocument.uri) == C.doc_buf then
                    document_edits = change.edits
                    break
                end
            end
        end
        if not document_edits then
            return
        end

        ---@type { range:lsp.Range, pattern:string? }[]
        local editing_ranges = {}
        ---@type lsp.Position
        local pos = C.rename_params.position
        local win_offset = 0
        for _, edit in ipairs(document_edits) do
            local pattern = nil
            if cfg.use_patterns and unique_name ~= edit.newText then
                local escaped = edit.newText:gsub("%%", "%%")
                pattern = escaped:gsub(unique_name, "%%s")
            end

            local range = edit.range
            if range.start.line ~= range["end"].line then
                return
            end
            if range.start.line ~= pos.line then
                -- on other line
                table.insert(editing_ranges, { range = range, pattern = pattern })
            elseif pos.character < range.start.character or pos.character > range["end"].character then
                -- on same line but not inside the character range
                if pos.character > range["end"].character then
                    local len = range["end"].character - range.start.character
                    win_offset = win_offset + len
                end
                table.insert(editing_ranges, { range = range, pattern = pattern })
            end
        end

        -- update window position
        if win_offset > 0 then
            local win_opts = {
                -- relative to buffer text
                relative = "win",
                win = C.doc_win,
                bufpos = { C.cword.line, C.cword.start_col },
                row = 0,
                -- correct for extmarks on the same line
                col = -win_offset
            }
            vim.api.nvim_win_set_config(C.float_win, win_opts)
        end

        -- also show edit in other occurrences
        C.editing_ranges = {}
        for _, e in ipairs(editing_ranges) do
            local line = e.range.start.line
            local start_col, end_col = range_to_cols(C.client, C.doc_buf, e.range)

            local new_text = C.new_text
            if e.pattern then
                new_text = string.format(e.pattern, new_text)
            end
            local extmark_id = vim.api.nvim_buf_set_extmark(C.doc_buf, extmark_ns, line, start_col, {
                end_col = end_col,
                virt_text_pos = "inline",
                virt_text = { { new_text, cfg.hl.others } },
                conceal = "",
            })

            table.insert(C.editing_ranges, {
                extmark_id = extmark_id,
                line = line,
                start_col = start_col,
                end_col = end_col,
                pattern = e.pattern,
            })
        end
    end
end

---@param buf integer
local function remove_key_mappings(buf)
    for _, k in ipairs(cfg.keys.submit) do
        vim.keymap.del(k[1], k[2], { buffer = buf })
    end
    for _, k in ipairs(cfg.keys.cancel) do
        vim.keymap.del(k[1], k[2], { buffer = buf })
    end
end

---@param buf integer
local function setup_key_mappings(buf)
    for _, k in ipairs(cfg.keys.submit) do
        vim.keymap.set(k[1], k[2], M.submit, { buffer = buf, desc = "Submit rename" })
    end
    for _, k in ipairs(cfg.keys.cancel) do
        vim.keymap.set(k[1], k[2], M.hide, { buffer = buf, desc = "Cancel rename" })
    end
end

---@class RenameOpts
---@field text string?
---@field insert boolean?
-- If nil, maintin the cursor position within the word.
-- If a positive integer, jump to the position.
-- If a negative integer, jump to the position starting from -1, which
-- corresponds to the end of the word.
---@field cursorpos integer?
---@field macrorepeat boolean?
---@field noconfirm boolean?

---@param opts RenameOpts
local function rename(opts)
    local doc_buf = vim.api.nvim_get_current_buf()
    local doc_win = vim.api.nvim_get_current_win()

    -- find client that supports renaming
    local clients = vim.lsp.get_clients({
        bufnr = doc_buf,
        method = lsp_methods.textDocument_rename,
    })
    local client = clients[1]
    if not client then
        vim.notify("[LSP] rename, no matching server attached")
        return
    end
    client = wrap_client(client)

    ---@type lsp.TextDocumentPositionParams
    local position_params = vim.lsp.util.make_position_params(doc_win, client.offset_encoding)

    local initial_pos = vim.api.nvim_win_get_cursor(doc_win)

    ---@type CursorWord?
    local cword = nil
    -- get word to rename
    if cfg.prepare_rename and client:supports_method(lsp_methods.textDocument_prepareRename) then
        local resp = lsp_request_sync(client, lsp_methods.textDocument_prepareRename, position_params, doc_buf)
        if not resp or resp.err ~= nil or resp.result == nil then
            if resp and resp.err then
                notify_error("[LSP] rename, error preparing rename", resp and resp.err)
            else
                notify_error("[LSP] rename, invalid position")
            end
            return
        end

        ---@type lsp.PrepareRenameResult
        local result = resp.result

        if result.defaultBehavior then
            -- fallback
        elseif result.range then
            local start_col, end_col = range_to_cols(client, doc_buf, result.range)
            cword = {
                line = result.range.start.line,
                start_col = start_col,
                end_col = end_col,
                text = tostring(result.placeholder),
                initial_offset = initial_pos[2] - start_col,
            }
        else
            ---@cast result lsp.Range
            local range = result
            local line = range.start.line
            local lines = vim.api.nvim_buf_get_lines(doc_buf, line, line + 1, true)
            local start_col, end_col = range_to_cols(client, doc_buf, range)

            cword = {
                line = line,
                start_col = start_col,
                end_col = end_col,
                text = string.sub(lines[1], start_col + 1, end_col),
                initial_offset = initial_pos[2] - start_col,
            }
        end
    end

    -- use <cword> as a fallback
    if not cword then
        -- search backward for next word
        vim.fn.search("\\w\\+", "bcW")
        local new_pos = vim.api.nvim_win_get_cursor(doc_win)
        local text = vim.fn.expand("<cword>")

        -- restore cursor position
        vim.api.nvim_win_set_cursor(0, initial_pos)

        local on_same_line = new_pos[1] == initial_pos[1]
        -- we only need to check the end bound since we're searching backwards
        local in_char_range = new_pos[2] + #text >= initial_pos[2]
        if text == "" or not on_same_line or not in_char_range then
            notify_error("[LSP] rename, no word found")
            return
        end
        cword = {
            line = new_pos[1] - 1,
            start_col = new_pos[2],
            end_col = new_pos[2] + #text,
            text = text,
            initial_offset = initial_pos[2] - new_pos[2],
        }
    end

    local text = opts.text or cword.text
    local text_width = vim.fn.strdisplaywidth(text)

    local transaction_id = math.random()
    ---@type lsp.RenameParams
    local rename_params = {
        textDocument = position_params.textDocument,
        position = position_params.position,
        newName = cword.text,
    }
    if cfg.show_other_ocurrences then
        -- make initial request to receive edit ranges

        -- HACK: try to use a unique enough name that
        -- 1. is different from the current name
        -- 2. doesn't conflict with other identiiers
        local unique_name = cword.text ~= "kajshfybcwriwuybqkjh" and "kajshfybcwriwuybqkjh" or "boviutyiiehsihjdlkgh"

        -- match the casing of the word to be renamed as to not indroduce unwanted side effects
        -- in some languages, like Go, uppercase identifiers are public
        if cword.text:sub(1, 1):match("%u") then
            unique_name = unique_name:sub(1, 1):upper() .. unique_name:sub(2)
        end

        rename_params.newName = unique_name
        local handler = rename_refs_handler(transaction_id, unique_name)
        client:request(lsp_methods.textDocument_rename, rename_params, handler, doc_buf)
    end

    -- conceal word in document with spaces, requires at least concealleval=2
    local prev_conceallevel = vim.wo[doc_win].conceallevel
    vim.wo[doc_win].conceallevel = 2

    local extmark_id = vim.api.nvim_buf_set_extmark(doc_buf, extmark_ns, cword.line, cword.start_col, {
        end_col = cword.end_col,
        virt_text_pos = "inline",
        virt_text = { { string.rep(" ", text_width), cfg.hl.current } },
        conceal = "",
    })

    -- create buf
    local float_buf = vim.api.nvim_create_buf(false, true)
    vim.api.nvim_buf_set_name(float_buf, "lsp:rename")
    vim.api.nvim_buf_set_lines(float_buf, 0, 1, false, { text })

    -- create win
    local win_opts = {
        -- relative to buffer text
        relative = "win",
        win = doc_win,
        bufpos = { cword.line, cword.start_col },
        row = 0,
        col = 0,

        width = text_width + 2,
        height = 1,
        style = "minimal",
        border = "none",
    }
    local float_win = vim.api.nvim_open_win(float_buf, false, win_opts)
    vim.wo[float_win].wrap = true

    -- highlights and transparency
    vim.api.nvim_set_option_value("winblend", 100, {
        scope = "local",
        win = float_win,
    })
    vim.api.nvim_set_hl(win_hl_ns, "Normal", { fg = nil, bg = nil })
    vim.api.nvim_win_set_hl_ns(float_win, win_hl_ns)

    -- key mappings
    setup_key_mappings(float_buf)

    local group = vim.api.nvim_create_augroup("live-rename", {})
    -- update when input changes
    vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI", "TextChangedP", "CursorMoved" }, {
        group = group,
        buffer = float_buf,
        callback = M.update,
    })
    -- cleanup when leaving window
    vim.api.nvim_create_autocmd("WinLeave", {
        group = group,
        buffer = float_buf,
        callback = M.hide,
        once = true,
    })

    -- focus and enter insert mode
    vim.api.nvim_set_current_win(float_win)

    ---@type Context
    C = {
        doc_buf = doc_buf,
        doc_win = doc_win,
        float_win = float_win,
        float_buf = float_buf,
        cword = cword,
        new_text = text,
        extmark_id = extmark_id,
        opts = opts,

        client = client,
        prev_conceallevel = prev_conceallevel,
        ref_transaction_id = transaction_id,
        rename_params = rename_params,
    }

    if opts.insert then
        vim.cmd.startinsert()
    end

    local pos = cword.initial_offset
    if opts.cursorpos then
        if opts.cursorpos >= 0 then
            pos = opts.cursorpos
        else
            pos = text_width + 1 + opts.cursorpos
        end
    end
    vim.api.nvim_win_set_cursor(float_win, { 1, pos })

    -- TODO: is this schedule actually needed?
    vim.schedule(function()
        local append = false
        if opts.macrorepeat then
            append = true
            M.run_macro(float_buf)
        end

        if opts.noconfirm then
            M.update()
            vim.schedule(M.submit)
            return
        end

        M.start_recording_macro(float_buf, { append = append })
    end)
end


-- Rename options used once by the operatorfunc.
---@type RenameOpts?
local last_opts = nil

function M.rename_operatorfunc()
    local opts = vim.deepcopy(last_opts or {})
    opts.macrorepeat = true
    opts.noconfirm = true
    rename(opts)
end

---@param opts RenameOpts?
function M.rename(opts)
    last_opts = opts or {}
    rename(opts or {})
end

---@type string?
local recorded_macro = nil
local recording = false

-- Run any action with plugin key mappings for cancelling or submitting the
-- current rename session unbound.
---@param float_buf integer
---@param action fun()
local function with_keys_unbound(float_buf, action)
    remove_key_mappings(float_buf)
    action()
    setup_key_mappings(float_buf)
end

---@param float_buf integer
---@param opts {append:boolean}
function M.start_recording_macro(float_buf, opts)
    if recording then
        return
    end

    local register = cfg.scratch_register
    if opts.append then
        -- uppercase register name to append to it
        register = cfg.scratch_register:upper()
    end

    with_keys_unbound(float_buf, function()
        vim.cmd.normal("q" .. register)
    end)

    recording = true
end

---@param float_buf integer
---@param save boolean
---@param from_insert boolean
function M.stop_recording_macro(float_buf, save, from_insert)
    if not recording then
        return
    end

    with_keys_unbound(float_buf, function()
        vim.cmd.normal("q")
    end)

    if save then
        recorded_macro = vim.fn.getreg(cfg.scratch_register)
        if vim.endswith(recorded_macro, "\r") then
            recorded_macro = string.sub(recorded_macro, 1, -2)
        end
        if from_insert then
            -- If the user submitted in insert mode append an `<esc>`
            -- to fixup the macro.
            recorded_macro = recorded_macro .. "\27"
        end
    end

    recording = false
end

---@param float_buf integer
function M.run_macro(float_buf)
    if recorded_macro then
        local register = cfg.scratch_register
        vim.fn.setreg(register, recorded_macro)
        with_keys_unbound(float_buf, function()
            vim.cmd.normal("@" .. register)
        end)
    end
end

function M.update()
    if C == nil then
        return
    end

    C.new_text = vim.api.nvim_buf_get_lines(C.float_buf, 0, 1, false)[1]
    local text_width = vim.fn.strdisplaywidth(C.new_text)

    vim.api.nvim_buf_set_extmark(C.doc_buf, extmark_ns, C.cword.line, C.cword.start_col, {
        id = C.extmark_id,
        end_col = C.cword.end_col,
        virt_text_pos = "inline",
        virt_text = { { string.rep(" ", text_width), cfg.hl.current } },
        conceal = "",
    })

    -- also show edit in other occurrences
    if C.editing_ranges then
        for _, e in ipairs(C.editing_ranges) do
            local new_text = C.new_text
            if e.pattern then
                new_text = string.format(e.pattern, new_text)
            end
            vim.api.nvim_buf_set_extmark(C.doc_buf, extmark_ns, e.line, e.start_col, {
                id = e.extmark_id,
                end_col = e.end_col,
                virt_text_pos = "inline",
                virt_text = { { new_text, cfg.hl.others } },
                conceal = "",
            })
        end
    end

    vim.api.nvim_buf_clear_namespace(C.float_buf, buf_hl_ns, 0, -1)
    vim.api.nvim_buf_set_extmark(C.float_buf, buf_hl_ns, 0, 0, {
        end_col = #C.new_text,
        hl_group = cfg.hl.current,
    })

    -- avoid line wrapping due to the window being to small
    vim.api.nvim_win_set_width(C.float_win, text_width + 2)
end

---@param callback fun(boolean)
local function with_leave_insert(callback)
    local mode = vim.api.nvim_get_mode().mode;
    if mode == "i" then
        vim.cmd.stopinsert()
        vim.schedule(function()
            callback(true)
        end)
    else
        callback(false)
    end
end

---@param ctx Context
local function hide(ctx)
    vim.wo[ctx.doc_win].conceallevel = ctx.prev_conceallevel
    vim.api.nvim_buf_clear_namespace(ctx.doc_buf, extmark_ns, 0, -1)

    if vim.api.nvim_win_is_valid(ctx.float_win) then
        vim.api.nvim_win_close(ctx.float_win, false)
    end

    if vim.api.nvim_buf_is_valid(ctx.float_buf) then
        vim.api.nvim_buf_delete(ctx.float_buf, {})
    end
end

function M.hide()
    local ctx = C
    if ctx then
        with_leave_insert(function(from_insert)
            M.stop_recording_macro(ctx.float_buf, false, from_insert)
            hide(ctx)
        end)
    end
    C = nil
end

---@param ctx Context
local function submit(ctx)
    -- do a sync request to avoid flicker when deleting extmarks
    ctx.rename_params.newName = ctx.new_text
    local resp = lsp_request_sync(ctx.client, lsp_methods.textDocument_rename, ctx.rename_params, ctx.doc_buf)
    if resp then
        local handler = ctx.client.handlers[lsp_methods.textDocument_rename]
            or vim.lsp.handlers[lsp_methods.textDocument_rename]
        handler(resp.err, resp.result, resp.context, resp.config)
    end

    -- Make the rename dot-repeatable
    vim.go.operatorfunc = "{->''}"
    vim.cmd.normal("g@l")
    vim.go.operatorfunc = "v:lua.require'live-rename'.rename_operatorfunc"

    hide(ctx)
end

function M.submit()
    local ctx = assert(C)
    C = nil

    with_leave_insert(function(from_insert)
        M.stop_recording_macro(ctx.float_buf, true, from_insert)
        submit(ctx)
    end)
end

return M
