-- builtin/runtime/lsp.lua --- T M4.12 default LSP integration.
--
-- Wires the LSP request/response surface into a usable editor UX:
--   * Declarative server config (`pmacs.lsp.config[language]`).
--   * Auto-attach + did_open / did_change / did_close on buffer events.
--   * `pmacs.lsp.go_to_definition` / `pmacs.lsp.format_buffer` /
--     `pmacs.lsp.hover_at_cursor` / `pmacs.lsp.signature_help_at_cursor`
--     / `pmacs.lsp.rename` / `pmacs.lsp.code_actions` /
--     `pmacs.lsp.inlay_hints` / `pmacs.lsp.semantic_tokens`, bound to
--     default chords below.
--
-- Scope: one server per language across all buffers; async-await
-- request/react (the editor never blocks). Landed: cross-file
-- go-to-definition (L1), multi-file rename / WorkspaceEdit applier
-- (L2) with `textDocument/prepareRename` gating when the server
-- supports it, code actions + `workspace/executeCommand` + server→client
-- `workspace/applyEdit` (L3), ordered resource-op edits
-- (create/rename/delete file) with buffer-registry reconciliation
-- (L4), inlay hints, and semantic tokens (each data + modeline;
-- wiring them into rendering is a separate rendering milestone),
-- incl. the server→client `workspace/inlayHint/refresh` and
-- `workspace/semanticTokens/refresh` requests, and dynamic
-- `workspace/didChangeWatchedFiles` registration backed by a
-- polling snapshot-diff watcher.

pmacs.lsp = pmacs.lsp or {}
-- Per-language server config. Each entry:
--   command       (string)  required — server binary
--   args          (list)    argv after command
--   env           (table)   extra environment
--   init_options  (table)   `initializationOptions`
--   settings      (table)   answered to `workspace/configuration`
--   root          (string)  optional explicit project root; overrides
--                           the `pmacs.project.detect` marker walk used
--                           to set `rootUri`/`cwd` (see project_root_for)
pmacs.lsp.config = pmacs.lsp.config or {}

-- Default rust-analyzer config. Users replace any field from init.lua
-- before any rust file opens.
pmacs.lsp.config.rust = pmacs.lsp.config.rust or {
  command = "rust-analyzer",
  args = {},
  init_options = {
    cargo = { allFeatures = true },
    checkOnSave = { command = "clippy" },
    procMacro = { enable = true },
  },
}

-- Default Python config: basedpyright (an MIT fork of pyright that
-- re-enables inlay hints / semantic tokens in the open-source server,
-- which upstream pyright withholds for Pylance). `--stdio` is the
-- LSP transport. `settings` is answered to basedpyright's
-- `workspace/configuration` pull (pmacs now advertises that
-- capability) — `basic` keeps the diagnostics gutter from being
-- flooded by the fork's stricter defaults. A project's
-- `pyrightconfig.json` / `[tool.pyright]` still wins where present.
-- Users override any field from init.lua before a .py opens;
-- swapping to upstream pyright is just `command = "pyright-langserver"`.
pmacs.lsp.config.python = pmacs.lsp.config.python or {
  command = "basedpyright-langserver",
  args = { "--stdio" },
  settings = {
    python = { analysis = { typeCheckingMode = "basic" } },
    basedpyright = { analysis = { typeCheckingMode = "basic" } },
  },
}

-- C / C++ via clangd. One clangd binary serves both; `config.c` and
-- `config.cpp` are separate entries only so the `language_id` sent in
-- `didOpen` is accurate (clangd respects it). clangd takes its
-- project model from `compile_commands.json` / `compile_flags.txt`
-- at the project root, not `workspace/configuration`, so no
-- `settings` here; `--background-index` enables cross-file features.
-- Users override from init.lua before a C/C++ file opens.
pmacs.lsp.config.c = pmacs.lsp.config.c or {
  command = "clangd",
  args = { "--background-index" },
}
pmacs.lsp.config.cpp = pmacs.lsp.config.cpp or {
  command = "clangd",
  args = { "--background-index" },
}

-- Go via gopls. `gopls` with no args serves LSP over stdio. gopls
-- pulls its configuration via `workspace/configuration` (now
-- answered, #13) under the `gopls` section; an empty section means
-- "use defaults" — present, not null, which gopls prefers. Users
-- populate it (e.g. staticcheck, analyses) from init.lua.
pmacs.lsp.config.go = pmacs.lsp.config.go or {
  command = "gopls",
  args = {},
  settings = { gopls = {} },
}

-- TypeScript / JavaScript via typescript-language-server (the
-- tsserver wrapper). One binary serves the whole family; like
-- `c`/`cpp` these are separate config entries purely so the
-- `language_id` sent in `didOpen` is accurate — tsserver keys
-- diagnostics and some code actions off it (typescript /
-- typescriptreact / javascript / javascriptreact). `--stdio` is the
-- LSP transport. The project model comes from `tsconfig.json` /
-- `jsconfig.json` (no `workspace/configuration` pull). Users
-- override from init.lua before a TS/JS file opens; swapping to
-- `vtsls` is just `command = "vtsls"`.
for _, lid in ipairs({ "typescript", "typescriptreact", "javascript", "javascriptreact" }) do
  pmacs.lsp.config[lid] = pmacs.lsp.config[lid] or {
    command = "typescript-language-server",
    args = { "--stdio" },
  }
end

-- Lua via lua-language-server (sumneko). Speaks LSP over stdio with
-- no transport flag. It pulls configuration via
-- `workspace/configuration` under the `Lua` section; an empty table
-- is "use defaults" — present, not null (same rationale as gopls).
-- `.lua` is tree-sitter-grammar-backed, so `pmacs.parse` already
-- resolves the `lua` language and this is the config that attaches.
-- Users populate `settings.Lua` (runtime.version, workspace.library,
-- diagnostics.globals = { "pmacs" }, …) from init.lua.
pmacs.lsp.config.lua = pmacs.lsp.config.lua or {
  command = "lua-language-server",
  args = {},
  settings = { Lua = {} },
}

-- Bash / shell via bash-language-server. `start` is its
-- LSP-over-stdio subcommand. No project config; it shells out to
-- `shellcheck` (diagnostics) and `shfmt` (formatting) when those are
-- on PATH. Users override from init.lua before a shell file opens.
pmacs.lsp.config.bash = pmacs.lsp.config.bash or {
  command = "bash-language-server",
  args = { "start" },
}

-- TOML via taplo. `taplo lsp stdio` serves LSP over stdio. taplo
-- pulls configuration via `workspace/configuration` under the
-- `taplo` section (empty ⇒ defaults, present not null); a project
-- `.taplo.toml` still wins. Users populate it from init.lua.
pmacs.lsp.config.toml = pmacs.lsp.config.toml or {
  command = "taplo",
  args = { "lsp", "stdio" },
  settings = { taplo = {} },
}

-- Zig via zls. `zls` with no args serves LSP over stdio; it reads
-- `zls.json` / `build.zig` for the project model (no
-- `workspace/configuration` pull). Users override from init.lua
-- before a `.zig` / `.zon` file opens.
pmacs.lsp.config.zig = pmacs.lsp.config.zig or {
  command = "zls",
  args = {},
}

-- LSP-side extension → language map, deliberately independent of the
-- tree-sitter detection in `pmacs.parse` (which is grammar-gated:
-- Python has an LSP server but no bundled grammar). Consulted only
-- when `pmacs.parse.language_for_path` finds nothing, so grammar-
-- backed languages keep their existing detection. Extensible from
-- init.lua: `pmacs.lsp.filetypes.foo = "bar"`.
pmacs.lsp.filetypes = pmacs.lsp.filetypes or {}
pmacs.lsp.filetypes.py = pmacs.lsp.filetypes.py or "python"
pmacs.lsp.filetypes.pyi = pmacs.lsp.filetypes.pyi or "python"
-- C. `.h` is ambiguous C/C++; default it to C (clangd copes either
-- way, and users can remap `pmacs.lsp.filetypes.h = "cpp"`).
pmacs.lsp.filetypes.c = pmacs.lsp.filetypes.c or "c"
pmacs.lsp.filetypes.h = pmacs.lsp.filetypes.h or "c"
-- C++.
for _, ext in ipairs({ "cpp", "cc", "cxx", "hpp", "hh", "hxx", "ipp", "inl", "cppm" }) do
  pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "cpp"
end
-- Go.
pmacs.lsp.filetypes.go = pmacs.lsp.filetypes.go or "go"
-- Tier 1 single-binary servers. TypeScript / JavaScript distinguish
-- the JSX variants so the server enables the JSX parser.
for _, ext in ipairs({ "ts", "mts", "cts" }) do
  pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "typescript"
end
pmacs.lsp.filetypes.tsx = pmacs.lsp.filetypes.tsx or "typescriptreact"
for _, ext in ipairs({ "js", "mjs", "cjs" }) do
  pmacs.lsp.filetypes[ext] = pmacs.lsp.filetypes[ext] or "javascript"
end
pmacs.lsp.filetypes.jsx = pmacs.lsp.filetypes.jsx or "javascriptreact"
-- Lua (lua-language-server). pmacs bundles a Lua grammar, so
-- `language_for_path` resolves `.lua` first; this entry is the LSP
-- fallback and keeps the language id stable if that ever changes.
pmacs.lsp.filetypes.lua = pmacs.lsp.filetypes.lua or "lua"
-- Bash / shell (bash-language-server).
pmacs.lsp.filetypes.sh = pmacs.lsp.filetypes.sh or "bash"
pmacs.lsp.filetypes.bash = pmacs.lsp.filetypes.bash or "bash"
-- TOML (taplo).
pmacs.lsp.filetypes.toml = pmacs.lsp.filetypes.toml or "toml"
-- Zig (zls). `.zon` is Zig Object Notation, handled by the same server.
pmacs.lsp.filetypes.zig = pmacs.lsp.filetypes.zig or "zig"
pmacs.lsp.filetypes.zon = pmacs.lsp.filetypes.zon or "zig"

-- Per-buffer attachment record: { language, server, uri, version }.
-- Keyed by `tostring(BufferIdLua)` because BufferIdLua hands out fresh
-- userdata each call (so two handles to the same buffer wouldn't hash
-- equal as raw keys).
local attachments = {}

-- Minimal file:// percent-encoder. Matches src/lsp.rs's policy: ASCII
-- alpha-num + a small set of path-safe punctuation pass through; every
-- other byte goes through %XX. Iterates per-byte (`gmatch(".")` is
-- byte-wise) so multibyte UTF-8 components encode cleanly.
local function file_uri_for(path)
  if not path then return nil end
  local out = "file://"
  for ch in path:gmatch(".") do
    local b = string.byte(ch)
    if (b >= 48 and b <= 57)         -- 0-9
        or (b >= 65 and b <= 90)     -- A-Z
        or (b >= 97 and b <= 122)    -- a-z
        or b == 47 or b == 45 or b == 95
        or b == 46 or b == 126 or b == 58 then
      out = out .. ch
    else
      out = out .. string.format("%%%02X", b)
    end
  end
  return out
end

local function active_buffer_text()
  local b = pmacs.window.buffer()
  if not b then return "" end
  return b:slice(0, b:len())
end

local function buffer_text(buf)
  if not buf then return "" end
  return buf:slice(0, buf:len())
end

-- didChange coalescing (typing perf) -----------------------------------------
--
-- Document sync is full-text, so each `textDocument/didChange` ships
-- the entire buffer. Sending one per keystroke cost three O(file)
-- copies plus an O(file) JSON write to the server pipe *per typed
-- character* — the dominant daemon-side typing cost on large files.
-- The after-edit hook now only bumps the version, marks the cached
-- render families stale (cheap), and records the buffer as dirty;
-- the actual notification ships from the async tick once the buffer
-- has been quiet for DID_CHANGE_QUIET_MS, or unconditionally once
-- the oldest unsent edit is DID_CHANGE_MAX_LAG_MS old (so the server
-- keeps converging during continuous typing). Versions may skip
-- values across a coalesced burst; LSP only requires that they
-- increase. Anything that asks the server about a document flushes
-- it first so no request is answered against stale text.
local DID_CHANGE_QUIET_MS = 75
local DID_CHANGE_MAX_LAG_MS = 400

-- Dirty buffers: key (tostring(buf)) -> {
--   rec      = the attachment record the edits belong to,
--   first_ms = monotonic time of the oldest unsent edit,
--   last_ms  = monotonic time of the newest unsent edit,
-- }
local pending_did_change = {}

-- Forward declaration — defined below (needs helpers that follow);
-- `flush_did_change` re-pulls inlay hints after each coalesced send.
local pull_inlay_hints_quiet

local function flush_did_change(key)
  local pending = pending_did_change[key]
  if not pending then return end
  pending_did_change[key] = nil
  local rec = pending.rec
  -- The attachment may have been torn down or replaced (server
  -- crash -> re-attach) since the edit was recorded; only the live
  -- record's server should hear about the buffer.
  if attachments[key] ~= rec then return end
  local ok, text = pcall(buffer_text, rec.buffer)
  if not ok then return end
  pcall(pmacs.lsp.did_change, rec.server, rec.uri, rec.version, text)
  -- Inlay hints are pull-model: the store's stale flag (set per edit)
  -- only clears on a fresh `textDocument/inlayHint` response, and the
  -- server never volunteers one. Re-request at flush cadence so
  -- hints come back shortly after each pause instead of staying
  -- suppressed until the next attach/refresh. The request is
  -- supersede-keyed per (server, method, uri), so a burst of flushes
  -- cancels its own predecessors rather than piling up.
  pcall(pull_inlay_hints_quiet, rec)
end

local function flush_did_change_for(rec)
  if rec and rec.buffer then flush_did_change(tostring(rec.buffer)) end
end

local function flush_all_did_changes()
  for key in pairs(pending_did_change) do
    flush_did_change(key)
  end
end

local function flush_due_did_changes()
  if next(pending_did_change) == nil then return end
  local now = pmacs.editor.monotonic_ms()
  for key, pending in pairs(pending_did_change) do
    if now - pending.last_ms >= DID_CHANGE_QUIET_MS
        or now - pending.first_ms >= DID_CHANGE_MAX_LAG_MS then
      flush_did_change(key)
    end
  end
end

-- Exposed for tests and for glue that must synchronize the server's
-- document view before an out-of-band operation (e.g. a save hook).
function pmacs.lsp._flush_did_changes()
  flush_all_did_changes()
end

local function document_end_position(text)
  local line, col = 0, 0
  for i = 1, #text do
    if text:byte(i) == 10 then
      line = line + 1
      col = 0
    else
      col = col + 1
    end
  end
  return line, col
end

local function active_buffer_path()
  return pmacs.editor.file_path()
end

local function active_buffer_language()
  local path = active_buffer_path()
  if not path then return nil end
  -- Grammar-backed detection first (keeps rust/.rs etc. exactly as
  -- before); fall back to the LSP-only filetype map so languages
  -- with a server but no tree-sitter grammar (Python) still attach.
  local lang = pmacs.parse.language_for_path(path)
  if lang then return lang end
  local ext = path:match("%.([%w_]+)$")
  return ext and pmacs.lsp.filetypes[ext] or nil
end

-- Directory component of a path, or nil if it has none.
local function dir_of(path)
  if not path then return nil end
  return path:match("^(.*)/[^/]*$")
end

-- The project root to send as `rootUri` / `cwd` in `initialize`, for
-- the file at `path`. Without this a server spawned by the auto-attach
-- hook gets the *editor's* cwd as its root (the `build_initialize`
-- fallback), which is wrong for project-model-strict servers: gopls
-- and rust-analyzer only analyze files under the module/workspace at
-- `rootUri`, so opening a file from outside the launch directory
-- yields zero diagnostics/hover/definition. Resolution order:
--   1. explicit `pmacs.lsp.config[language].root` override,
--   2. `pmacs.project.detect` marker walk (go.mod / Cargo.toml /
--      pyproject.toml / package.json / .git …) — the same detector
--      the rest of the editor uses, honoring set_search_boundary,
--   3. the file's own directory (a lone file still gets a sane root
--      rather than leaking the editor cwd).
-- This is single-root: it fixes which root the one per-language server
-- uses, NOT one-server-per-root scoping (still deferred post-v0.1).
local function project_root_for(language, path)
  local cfg = pmacs.lsp.config[language]
  if cfg and cfg.root then return cfg.root end
  if not path then return nil end
  local ok, det = pcall(pmacs.project.detect, path)
  if ok and det and det.root then return det.root end
  return dir_of(path)
end

local function ensure_server(language, path)
  local cfg = pmacs.lsp.config[language]
  if not cfg or not cfg.command then return nil end
  -- Reuse an existing same-language server if one is up. Multi-root
  -- scoping (one server per project root) ships post-v0.1, so the
  -- first file that attaches a given language fixes that server's
  -- root; later files of the same language reuse it regardless of
  -- their own project (known, documented limitation).
  for _, info in ipairs(pmacs.lsp.list()) do
    if info.language_id == language and info.state then
      local kind = info.state.kind
      if kind ~= "crashed" and kind ~= "stopped" then
        return info.id
      end
    end
  end
  local root = project_root_for(language, path)
  local ok, sid = pcall(pmacs.lsp.spawn, {
    label = "default-" .. language,
    language_id = language,
    command = cfg.command,
    args = cfg.args or {},
    env = cfg.env,
    init_options = cfg.init_options,
    settings = cfg.settings,
    cwd = root,
    root_uri = root and file_uri_for(root) or nil,
  })
  if ok then return sid end
  return nil
end

-- True iff `sid` is still registered with the manager and isn't dead.
-- Stale attachments — left behind by a server that crashed, was
-- forgotten, or was spawned against a now-replaced `pmacs.lsp.config`
-- entry — get rebuilt on the next attach attempt.
local function server_is_live(sid)
  if not sid then return false end
  for _, info in ipairs(pmacs.lsp.list()) do
    if tostring(info.id) == tostring(sid) then
      local kind = info.state and info.state.kind
      return kind ~= "crashed" and kind ~= "stopped"
    end
  end
  return false
end

local function server_is_initialized(sid)
  local ok, state = pcall(pmacs.lsp.status, sid)
  return ok and state and state.kind == "initialized"
end

local function server_supports_inlay_hints(sid)
  local ok, caps = pcall(pmacs.lsp.capabilities, sid)
  if not ok or not caps then return false end
  return caps.inlayHintProvider ~= nil and caps.inlayHintProvider ~= false
end

-- Assigns the forward-declared local above (so `flush_did_change`
-- can re-pull); a fresh `local function` here would shadow it.
function pull_inlay_hints_quiet(rec)
  if not rec or not server_is_initialized(rec.server) then return end
  if not server_supports_inlay_hints(rec.server) then return end
  -- The server must see the current text before being asked to
  -- compute positions against it (didChange is debounced). A no-op
  -- when called from `flush_did_change` itself (the pending entry is
  -- removed before the send), so this cannot recurse.
  flush_did_change_for(rec)
  local end_line, end_col = document_end_position(buffer_text(rec.buffer))
  pmacs.async(function()
    pcall(function()
      pmacs.lsp.request_inlay_hint(
        rec.server, rec.uri, 0, 0, end_line, end_col):await()
    end)
  end)
end

-- M_B1: buffers that already had an `LspStyleView` overlay pushed,
-- so the after-load / on-demand attach paths don't stack duplicate
-- overlays. Mirrors `highlighted_buffers` in `syntax.lua`; the entry
-- is keyed by the same `tostring(buf)` the attachments table uses.
local styled_buffers = {}

-- M4.6 (task #23): buffers that already had a `DiagnosticView`
-- overlay pushed. Same dedup discipline as `styled_buffers` —
-- `pmacs.diag._attach_view` stacks a fresh overlay on every call,
-- so the after-load path must gate itself.
local diag_viewed_buffers = {}

local function attach_buffer(buf)
  if not buf then return nil end
  local key = tostring(buf)
  local existing = attachments[key]
  if existing and server_is_live(existing.server) then return existing end
  if existing then
    attachments[key] = nil
    -- Unsent edits targeted the dead attachment; the did_open below
    -- carries the full current text, superseding them.
    pending_did_change[key] = nil
  end
  local language = active_buffer_language()
  if not language then return nil end
  -- Path resolved before spawn so the server's `rootUri` can be
  -- derived from the file's project (see `project_root_for`).
  local path = active_buffer_path()
  local sid = ensure_server(language, path)
  if not sid then return nil end
  local uri = file_uri_for(path)
  if not uri then return nil end
  local rec = {
    buffer = buf,
    language = language,
    server = sid,
    uri = uri,
    version = 1,
  }
  attachments[key] = rec
  -- did_open is a notification; the manager queues it cleanly even
  -- while the server is in `starting` / `initializing`.
  pcall(pmacs.lsp.did_open, sid, uri, rec.version, active_buffer_text())
  -- M_B3: dual-authority styling. Always push the LSP style overlay
  -- when an LSP server is up — whether or not the buffer has a
  -- bundled tree-sitter grammar. When the grammar exists too,
  -- `SyntaxHighlightView` paints first (lexical: keywords, strings,
  -- operators) and `LspStyleView` paints after (semantic: function /
  -- type / macro / namespace identifiers from clangd's tokens); their
  -- styles compose via `crate::overlay::merge_styles`, so the final
  -- cell carries both authorities' contributions. This replaces
  -- M_B1's policy-A exclusivity, which left grammar-backed languages
  -- (Rust, C, C++) without LSP semantic refinement.
  if not styled_buffers[key] then
    local ok, attached = pcall(pmacs.lsp._attach_style, buf)
    if ok and attached then styled_buffers[key] = true end
  end
  -- M4.6 (task #23): attach the DiagnosticView so the TUI grid
  -- renderer underlines bytes covered by published diagnostics.
  -- Keyed by `uri` to match the diag store; the view re-reads the
  -- store on every render, so no further wiring is needed when
  -- diagnostics update.
  if not diag_viewed_buffers[key] then
    local ok, attached = pcall(pmacs.diag._attach_view, buf, uri)
    if ok and attached then diag_viewed_buffers[key] = true end
  end
  pull_inlay_hints_quiet(rec)
  return rec
end

local function attached_for_active()
  local buf = pmacs.window.buffer()
  if not buf then return nil end
  local key = tostring(buf)
  local rec = attachments[key]
  if rec then
    -- Every interactive command resolves its attachment here before
    -- issuing requests; flushing now means the server answers those
    -- requests against the current text (didChange is debounced).
    flush_did_change(key)
    return rec
  end
  return attach_buffer(buf)
end

-- Pure, side-effect-free attachment lookup for the active buffer:
-- returns the live record (with `.uri`) when a server is already
-- attached, else nil. Unlike `attached_for_active`, it never *triggers*
-- an attach --- the context menu (Q#CM3) calls it to decide whether to
-- show symbol/diagnostic items, and must not perturb LSP state just by
-- opening.
function pmacs.lsp.active_attachment()
  local buf = pmacs.window.buffer()
  if not buf then return nil end
  return attachments[tostring(buf)]
end

-- Hooks --------------------------------------------------------------------

pmacs.hook.add("buffer.after-load", function()
  pcall(attach_buffer, pmacs.window.buffer())
end)

pmacs.hook.add("buffer.after-edit", function()
  local buf = pmacs.window.buffer()
  if not buf then return end
  local key = tostring(buf)
  local rec = attachments[key]
  if not rec then return end
  rec.version = rec.version + 1
  -- Stale suppression must stay keystroke-accurate even though the
  -- O(file) didChange send below is coalesced: render families
  -- anchored to pre-edit positions are hidden from this edit on.
  pcall(pmacs.lsp._mark_document_stale, rec.uri)
  local now = pmacs.editor.monotonic_ms()
  local pending = pending_did_change[key]
  if pending and pending.rec == rec then
    pending.last_ms = now
  else
    pending_did_change[key] = { rec = rec, first_ms = now, last_ms = now }
  end
end)

-- Async request surface (T M4.5 async bridge). The Rust manager
-- registers each `textDocument/*` request with the async runtime and
-- returns a job id; the JSON-RPC response (or a server-teardown
-- drain) settles it. `_request_*_raw` is the raw job-id-returning
-- binding (mirrors `pmacs.mcp._send_request_raw`); the wrappers below
-- hand back a `pmacs.workers` Handle whose `:await()` resumes the
-- caller when the response lands. The pre-v1.0 `poll_until` tick-loop
-- this replaced blocked the editor for the whole request; awaiting
-- yields the coroutine instead.
local workers_mod = pmacs.workers
assert(workers_mod and workers_mod._new_handle,
  "pmacs.workers._new_handle missing; did async.lua load before lsp.lua?")
assert(pmacs.lsp._request_completion_raw,
  "pmacs.lsp._request_completion_raw missing; lua_bindings::install_lsp not run?")
local new_handle = workers_mod._new_handle

local function wrap_request(raw)
  -- Raises on dispatch failure (e.g. "server not ready"), matching
  -- `mcp.lua`'s wrapper; callers pcall the `request():await()` chain.
  return function(...)
    return new_handle(raw(...))
  end
end

pmacs.lsp.request_completion = wrap_request(pmacs.lsp._request_completion_raw)
pmacs.lsp.request_hover = wrap_request(pmacs.lsp._request_hover_raw)
pmacs.lsp.request_signature_help = wrap_request(pmacs.lsp._request_signature_help_raw)
pmacs.lsp.request_definition = wrap_request(pmacs.lsp._request_definition_raw)
pmacs.lsp.request_formatting = wrap_request(pmacs.lsp._request_formatting_raw)
pmacs.lsp.request_references = wrap_request(pmacs.lsp._request_references_raw)
pmacs.lsp.request_declaration = wrap_request(pmacs.lsp._request_declaration_raw)
pmacs.lsp.request_type_definition = wrap_request(pmacs.lsp._request_type_definition_raw)
pmacs.lsp.request_implementation = wrap_request(pmacs.lsp._request_implementation_raw)
pmacs.lsp.request_document_symbol = wrap_request(pmacs.lsp._request_document_symbol_raw)
pmacs.lsp.request_workspace_symbol = wrap_request(pmacs.lsp._request_workspace_symbol_raw)
pmacs.lsp.request_document_highlight = wrap_request(pmacs.lsp._request_document_highlight_raw)
pmacs.lsp.request_rename = wrap_request(pmacs.lsp._request_rename_raw)
pmacs.lsp.request_prepare_rename = wrap_request(pmacs.lsp._request_prepare_rename_raw)
pmacs.lsp.request_code_action = wrap_request(pmacs.lsp._request_code_action_raw)
pmacs.lsp.request_execute_command = wrap_request(pmacs.lsp._request_execute_command_raw)
pmacs.lsp.request_inlay_hint = wrap_request(pmacs.lsp._request_inlay_hint_raw)
pmacs.lsp.request_semantic_tokens = wrap_request(pmacs.lsp._request_semantic_tokens_raw)
pmacs.lsp.request_semantic_tokens_range =
  wrap_request(pmacs.lsp._request_semantic_tokens_range_raw)
pmacs.lsp.request_semantic_tokens_delta =
  wrap_request(pmacs.lsp._request_semantic_tokens_delta_raw)

-- Render an `:await()` failure into a modeline-friendly reason.
-- `Handle:await()` raises `{ tag = "cancelled", ... }` when the
-- server went away mid-request (drain in `lsp.rs`) and
-- `{ tag = "failed", message = ... }` for a JSON-RPC error response;
-- a raw dispatch failure surfaces as a plain string.
local function lsp_await_error(err)
  if type(err) == "table" then
    if err.tag == "cancelled" then
      return "server unavailable (request cancelled)"
    elseif err.tag == "failed" then
      return err.message or "server error"
    end
    return tostring(err.tag or "error")
  end
  return tostring(err)
end

-- Cursor positioning ------------------------------------------------------
--
-- LSP positions are 0-based (line, character). pmacs.editor.cursor_line
-- and pmacs.editor.cursor_col already return 0-based byte counts; for
-- ASCII / UTF-8 text without astral codepoints, byte == UTF-16 code
-- unit, which is what every shipped server actually accepts. Multi-byte
-- conversion lands with the v0.2 LSP hardening pass.

local function move_active_cursor_to(line, col)
  -- Walk via primitives so all overlay observers see the navigation.
  pmacs.editor.move_line_start()
  -- Move to row 0 first, then step down `line` rows.
  while pmacs.editor.cursor_line() > 0 do
    pmacs.editor.move_up()
  end
  for _ = 1, line do pmacs.editor.move_down() end
  for _ = 1, col do pmacs.editor.move_right() end
end

-- Compute the byte offset of (line, col) within `text` where lines are
-- separated by `\n`. Used by `apply_text_edits` to map LSP coordinates
-- to byte positions on the rope.
local function byte_offset_for(text, line, col)
  if line == 0 then return col end
  local pos = 0
  local current_line = 0
  while current_line < line do
    local nl = text:find("\n", pos + 1, true)
    if not nl then return #text end
    pos = nl
    current_line = current_line + 1
  end
  return pos + col
end

local function apply_text_edits(edits)
  if not edits or #edits == 0 then return 0 end
  local buf = pmacs.window.buffer()
  if not buf then return 0 end
  local text = active_buffer_text()
  -- Resolve every edit against the *original* text, then sort by start
  -- byte descending so each replacement leaves earlier offsets valid.
  local resolved = {}
  for _, e in ipairs(edits) do
    table.insert(resolved, {
      start = byte_offset_for(text, e.start_line, e.start_col),
      stop  = byte_offset_for(text, e.end_line, e.end_col),
      text  = e.new_text,
    })
  end
  table.sort(resolved, function(a, b) return a.start > b.start end)
  for _, e in ipairs(resolved) do
    if e.start == e.stop then
      buf:insert(e.start, e.text)
    elseif e.text == "" then
      buf:delete(e.start, e.stop)
    else
      buf:replace(e.start, e.stop, e.text)
    end
  end
  return #resolved
end

-- T M4.5 L2/L4 — apply a parsed LSP `WorkspaceEdit` given as the
-- ordered op list `pmacs.rename.ops` / `code_action.edit` /
-- `_parse_workspace_edit` hand back: each entry is tagged `op` =
-- "edit" | "create" | "rename" | "delete". Order is the server's and
-- is honoured exactly, because the spec sequences ops (a `create`
-- must precede the `edit` that fills the new file).
--
-- Atomicity: a true cross-buffer/disk transaction is out of scope, so
-- the applier refuses to mutate *anything* unless every URI it
-- touches resolves to a real file path first (`path_for_uri`). An op
-- naming an `untitled:`/non-file document aborts the whole edit
-- cleanly, origin buffer untouched, rather than half-applying.
--
-- Text edits go through `apply_text_edits` (offsets resolved against
-- that buffer's *original* text, applied reverse-start) after
-- `find_or_open` makes the target active. Resource ops go through
-- `pmacs.buffer.apply_resource_op` (filesystem + buffer-registry
-- reconciliation). The buffer the user invoked from is restored last
-- (best-effort: it may itself have been renamed/deleted). Returns
-- `edit_count, file_count, resource_op_count` on success, or
-- `nil, message` if the preflight rejected the edit.
local function apply_workspace_edit(ops)
  local plan = {}
  for _, op in ipairs(ops or {}) do
    if op.op == "edit" then
      if op.edits and #op.edits > 0 then
        local path = pmacs.lsp.path_for_uri(op.uri)
        if not path then return nil, "cannot resolve " .. tostring(op.uri) end
        plan[#plan + 1] = { kind = "edit", path = path, edits = op.edits }
      end
    elseif op.op == "create" then
      local path = pmacs.lsp.path_for_uri(op.uri)
      if not path then return nil, "cannot resolve " .. tostring(op.uri) end
      plan[#plan + 1] = {
        kind = "create", path = path,
        overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
      }
    elseif op.op == "rename" then
      local from = pmacs.lsp.path_for_uri(op.old_uri)
      local to = pmacs.lsp.path_for_uri(op.new_uri)
      if not from or not to then
        return nil, "cannot resolve rename " ..
          tostring(op.old_uri) .. " -> " .. tostring(op.new_uri)
      end
      plan[#plan + 1] = {
        kind = "rename", old_path = from, new_path = to,
        overwrite = op.overwrite, ignore_if_exists = op.ignore_if_exists,
      }
    elseif op.op == "delete" then
      local path = pmacs.lsp.path_for_uri(op.uri)
      if not path then return nil, "cannot resolve " .. tostring(op.uri) end
      plan[#plan + 1] = {
        kind = "delete", path = path,
        recursive = op.recursive, ignore_if_not_exists = op.ignore_if_not_exists,
      }
    end
  end
  if #plan == 0 then return 0, 0, 0 end
  local origin = active_buffer_path()
  local edit_total, files, res_ops = 0, 0, 0
  for _, item in ipairs(plan) do
    if item.kind == "edit" then
      pmacs.buffer.find_or_open(item.path)
      edit_total = edit_total + apply_text_edits(item.edits)
      files = files + 1
    else
      pmacs.buffer.apply_resource_op(item)
      res_ops = res_ops + 1
    end
  end
  -- Return the user to where they invoked from — best-effort, since
  -- that path may have just been renamed or deleted.
  if origin then pcall(pmacs.buffer.find_or_open, origin) end
  return edit_total, files, res_ops
end

-- Re-pull a per-`(server, uri)` store for every buffer attached to
-- `sid`. Fire-and-forget: the response absorbs into its store via the
-- request's route, exactly like the explicit command path — no await
-- needed. `request_fn(sid, uri)` issues the re-pull.
local function repull_for_attachments(sid, request_fn)
  for _, rec in pairs(attachments) do
    if rec.server == sid and rec.uri then
      -- Server-initiated repulls (diagnostics refresh, semantic
      -- tokens refresh) must also see the latest text first.
      flush_did_change_for(rec)
      pcall(request_fn, sid, rec.uri, rec)
    end
  end
end

-- T M4.5 — workspace file watching (workspace/didChangeWatchedFiles).
--
-- Servers register watchers dynamically via client/registerCapability.
-- pmacs has no kernel file-watch, so each registration runs a polling
-- snapshot-diff coroutine: walk the base dir into a { relpath = sig }
-- map and, every tick, diff against the previous map to emit per-file
-- created/changed/deleted FileEvents (filtered by the glob and the
-- WatchKind bitmask), batched into one notification. Coarser than an
-- inotify bridge but accurate; a watcher self-cancels when the server
-- dies or the capability is unregistered.

local FILE_WATCH_INTERVAL_MS = 250

-- file_watchers[tostring(sid)][registrationId] = list of watch records
-- ({ cancelled = bool, _sleep = handle? }), one per glob watcher.
local file_watchers = {}

-- WatchKind is a bitmask (Create=1, Change=2, Delete=4); test it
-- arithmetically so this stays valid under luajit (no 5.3 `&`).
local function kind_has(mask, bit)
  return mask % (bit * 2) >= bit
end

-- Expand `{a,b}` alternations into brace-free globs (nested handled
-- by recursing the remainder; unbalanced braces left literal).
local function expand_braces(glob)
  local open = glob:find("{", 1, true)
  if not open then return { glob } end
  local depth, close = 0, nil
  for i = open, #glob do
    local c = glob:sub(i, i)
    if c == "{" then
      depth = depth + 1
    elseif c == "}" then
      depth = depth - 1
      if depth == 0 then
        close = i
        break
      end
    end
  end
  if not close then return { glob } end
  local prefix, body, suffix =
    glob:sub(1, open - 1), glob:sub(open + 1, close - 1), glob:sub(close + 1)
  local parts, d2, start = {}, 0, 1
  for i = 1, #body do
    local c = body:sub(i, i)
    if c == "{" then
      d2 = d2 + 1
    elseif c == "}" then
      d2 = d2 - 1
    elseif c == "," and d2 == 0 then
      parts[#parts + 1] = body:sub(start, i - 1)
      start = i + 1
    end
  end
  parts[#parts + 1] = body:sub(start)
  local out = {}
  for _, alt in ipairs(parts) do
    for _, tail in ipairs(expand_braces(suffix)) do
      out[#out + 1] = prefix .. alt .. tail
    end
  end
  return out
end

-- Translate one brace-free glob to an anchored Lua pattern.
local function glob_one_to_pattern(glob)
  local p, i, n = "^", 1, #glob
  while i <= n do
    local c = glob:sub(i, i)
    if c == "*" then
      if glob:sub(i + 1, i + 1) == "*" then
        -- Lua patterns can't quantify a group, so `**/` (zero+ path
        -- segments) becomes the lazy `.-` (`.` spans `/`); a bare
        -- `**` becomes `.*`.
        if glob:sub(i + 2, i + 2) == "/" then
          p, i = p .. ".-", i + 3
        else
          p, i = p .. ".*", i + 2
        end
      else
        p, i = p .. "[^/]*", i + 1
      end
    elseif c == "?" then
      p, i = p .. "[^/]", i + 1
    elseif c == "[" then
      local j = i + 1
      if glob:sub(j, j) == "!" then j = j + 1 end
      if glob:sub(j, j) == "]" then j = j + 1 end
      while j <= n and glob:sub(j, j) ~= "]" do j = j + 1 end
      local cls = glob:sub(i + 1, j - 1):gsub("^!", "^")
      p, i = p .. "[" .. cls .. "]", j + 1
    else
      if c:match("[%(%)%.%%%+%-%^%$%[%]%*%?]") then
        p = p .. "%" .. c
      else
        p = p .. c
      end
      i = i + 1
    end
  end
  return p .. "$"
end

local function glob_matcher(glob)
  local pats = {}
  for _, g in ipairs(expand_braces(glob)) do
    pats[#pats + 1] = glob_one_to_pattern(g)
  end
  return function(rel)
    for _, pat in ipairs(pats) do
      if rel:match(pat) then return true end
    end
    return false
  end
end

-- Recursively list files under `base` → { relpath = sig }. `sig`
-- folds size+mtime+kind so a content/metadata change flips it.
-- Symlinks are recorded, not traversed (loop-safe). Awaits fs
-- primitives, so call from inside an async coroutine.
local function scan_tree(base, matches)
  local out = {}
  local function walk(dir, rel_prefix)
    local ok, entries = pcall(function()
      return pmacs.fs.read_dir(dir):await()
    end)
    if not ok or not entries then return end
    for _, e in ipairs(entries) do
      local rel = (rel_prefix == "") and e.name or (rel_prefix .. "/" .. e.name)
      if e.kind == "dir" then
        walk(dir .. "/" .. e.name, rel)
      elseif matches(rel) then
        out[rel] = table.concat({
          tostring(e.size), tostring(e.mtime),
          tostring(e.mtime_nsec), tostring(e.kind),
        }, "|")
      end
    end
  end
  walk(base, "")
  return out
end

local FC_CREATED, FC_CHANGED, FC_DELETED = 1, 2, 3

local function start_file_watcher(sid, base, glob, kind_mask, record)
  local matches = glob_matcher(glob)
  pmacs.async(function()
    local prev = scan_tree(base, matches)
    while not record.cancelled and server_is_live(sid) do
      local sh = pmacs.workers.sleep(FILE_WATCH_INTERVAL_MS)
      record._sleep = sh
      pcall(function() sh:await() end)
      record._sleep = nil
      if record.cancelled or not server_is_live(sid) then break end

      local cur = scan_tree(base, matches)
      local changes = {}
      for rel, sig in pairs(cur) do
        local was = prev[rel]
        if was == nil then
          if kind_has(kind_mask, 1) then
            changes[#changes + 1] =
              { uri = file_uri_for(base .. "/" .. rel), type = FC_CREATED }
          end
        elseif was ~= sig and kind_has(kind_mask, 2) then
          changes[#changes + 1] =
            { uri = file_uri_for(base .. "/" .. rel), type = FC_CHANGED }
        end
      end
      for rel in pairs(prev) do
        if cur[rel] == nil and kind_has(kind_mask, 4) then
          changes[#changes + 1] =
            { uri = file_uri_for(base .. "/" .. rel), type = FC_DELETED }
        end
      end
      if #changes > 0 then
        pcall(pmacs.lsp.did_change_watched_files, sid, changes)
      end
      prev = cur
    end
  end)
end

-- Resolve a GlobPattern (string | { baseUri, pattern }) to
-- (base_dir, pattern). A bare string with no base falls back to the
-- directory of an attached file on `sid` (best effort).
local function resolve_watcher(sid, gp)
  if type(gp) == "table" and gp.baseUri then
    return pmacs.lsp.path_for_uri(gp.baseUri), gp.pattern or "**"
  end
  if type(gp) == "string" then
    for _, rec in pairs(attachments) do
      if rec.server == sid and rec.uri then
        local p = pmacs.lsp.path_for_uri(rec.uri)
        local dir = p and p:match("^(.*)/[^/]*$")
        if dir then return dir, gp end
      end
    end
  end
  return nil, nil
end

local function register_file_watchers(sid, registrations)
  local skey = tostring(sid)
  file_watchers[skey] = file_watchers[skey] or {}
  for _, reg in ipairs(registrations or {}) do
    if reg.method == "workspace/didChangeWatchedFiles" then
      local recs = {}
      for _, w in ipairs((reg.registerOptions or {}).watchers or {}) do
        local base, pat = resolve_watcher(sid, w.globPattern)
        if base and pat then
          local r = { cancelled = false }
          recs[#recs + 1] = r
          start_file_watcher(sid, base, pat, w.kind or 7, r)
        end
      end
      file_watchers[skey][reg.id] = recs
    end
  end
end

local function unregister_file_watchers(sid, unregs)
  local byid = file_watchers[tostring(sid)]
  if not byid then return end
  for _, u in ipairs(unregs or {}) do
    if u.method == "workspace/didChangeWatchedFiles" and byid[u.id] then
      for _, r in ipairs(byid[u.id]) do
        r.cancelled = true
        if r._sleep then pcall(function() r._sleep:cancel() end) end
      end
      byid[u.id] = nil
    end
  end
end

-- T M4.5 — server→client request pump.
--
-- Some server→client *requests* are surfaced by the manager as a
-- `request` event on the server's event stream (the same "expose the
-- request to the consumer" path as `workspace/configuration`, minus a
-- built-in answer). Each async tick we drain attachment servers'
-- events and handle:
--
--   * `workspace/applyEdit` (L3) — apply the edit through the shared
--     applier, reply `{ applied }`. After a code action's
--     `executeCommand`, servers (rust-analyzer, gopls, …) deliver the
--     actual change this way.
--   * `workspace/inlayHint/refresh` /
--     `workspace/semanticTokens/refresh` — the server signals its
--     cached hints/tokens are stale; reply `null` and re-pull that
--     family for every attached document so the matching store
--     (`pmacs.inlay_hint` / `pmacs.semantic_tokens`) stays fresh.
--   * `client/registerCapability` / `client/unregisterCapability` —
--     start/stop the file-watch coroutines for any
--     `workspace/didChangeWatchedFiles` registration; reply `null`.
--
-- Only servers in `attachments` are drained, so a test (or package)
-- that owns its own directly-spawned server and reads its events
-- itself is unaffected. Server ids are snapshotted before the loop
-- because `apply_workspace_edit` → `find_or_open` can attach a new
-- buffer mid-iteration (mutating `attachments`).
local function handle_server_requests()
  local sids, seen = {}, {}
  for _, rec in pairs(attachments) do
    local sid = rec.server
    if sid then
      local k = tostring(sid)
      if not seen[k] then
        seen[k] = true
        sids[#sids + 1] = sid
      end
    end
  end
  for _, sid in ipairs(sids) do
    local ok, evs = pcall(pmacs.lsp.events_take, sid)
    if ok and evs then
      for _, ev in ipairs(evs) do
        if ev.kind == "request" and ev.method == "workspace/applyEdit" then
          local edit = ev.params and ev.params.edit
          local applied, reason = false, nil
          if edit then
            local parsed = pmacs.lsp._parse_workspace_edit(edit)
            local n, info = apply_workspace_edit(parsed.ops)
            if n then applied = true else reason = info end
          else
            reason = "missing edit"
          end
          local result = { applied = applied }
          if not applied then result.failureReason = tostring(reason) end
          pcall(pmacs.lsp.send_response, sid, ev.request_id, result)
        elseif ev.kind == "request"
            and ev.method == "workspace/inlayHint/refresh" then
          -- Result is `null` on success per the LSP spec; then
          -- re-pull so the store reflects the server's new state.
          pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
          repull_for_attachments(sid, function(_, _, rec)
            pull_inlay_hints_quiet(rec)
          end)
        elseif ev.kind == "request"
            and ev.method == "workspace/semanticTokens/refresh" then
          pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
          repull_for_attachments(sid, pmacs.lsp.request_semantic_tokens)
        elseif ev.kind == "request"
            and ev.method == "client/registerCapability" then
          pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
          pcall(register_file_watchers, sid,
            ev.params and ev.params.registrations)
        elseif ev.kind == "request"
            and ev.method == "client/unregisterCapability" then
          pcall(pmacs.lsp.send_response, sid, ev.request_id, nil)
          -- LSP spells the field "unregisterations".
          pcall(unregister_file_watchers, sid,
            ev.params and ev.params.unregisterations)
        elseif ev.kind == "initialized" then
          repull_for_attachments(sid, function(_, _, rec)
            pull_inlay_hints_quiet(rec)
          end)
        end
      end
    end
  end
end

if pmacs._async and pmacs._async.tick then
  local _prior_async_tick = pmacs._async.tick
  pmacs._async.tick = function(...)
    local ret = _prior_async_tick(...)
    pcall(handle_server_requests)
    pcall(flush_due_did_changes)
    return ret
  end
end

-- Commands ----------------------------------------------------------------

-- Each command captures the cursor/target at invocation time, then
-- spawns a coroutine that awaits the request and reacts. The editor
-- never blocks: the command function returns immediately and the
-- modeline updates when the response lands (or the await fails).
-- `:await()` sequences the work and surfaces server-gone / server-
-- error as structured errors; the normalized typed store (hybrid
-- model) is still the read path, so LSP result-shape variance
-- (Location | Location[] | LocationLink[], MarkupContent, …) stays
-- parsed in one place in Rust rather than re-derived here.

function pmacs.lsp.go_to_definition()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  pmacs.definition.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_definition(rec.server, rec.uri, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local locs = pmacs.definition.locations(rec.server, rec.uri)
    if not locs or #locs == 0 then
      pmacs.editor.set_status("LSP: no definition found")
      return
    end
    local first = locs[1]
    if first.uri == rec.uri then
      -- Same file: record the origin so M-, returns here, then move.
      pmacs.editor.push_jump()
      move_active_cursor_to(first.line, first.col)
      pmacs.editor.set_status(string.format(
        "LSP: definition at %d:%d", first.line + 1, first.col + 1))
    else
      -- Cross-file (SP-4): decode the URI, record the jump origin
      -- *before* switching away, open-or-reuse the target buffer,
      -- then position the cursor. `find_or_open` switches the active
      -- buffer and fires `buffer.after-load`, which attaches an LSP
      -- to the newly opened file.
      local path = pmacs.lsp.path_for_uri(first.uri)
      if not path then
        pmacs.editor.set_status(
          "LSP: cannot open non-file definition " .. first.uri)
        return
      end
      pmacs.editor.push_jump()
      local ok2, oerr = pcall(pmacs.buffer.find_or_open, path)
      if not ok2 then
        -- Open failed: drop the origin we just pushed so M-, isn't
        -- left pointing at a jump that never happened.
        pmacs.editor.jump_back()
        pmacs.editor.set_status(
          "LSP: failed to open " .. path .. ": " .. tostring(oerr))
        return
      end
      move_active_cursor_to(first.line, first.col)
      pmacs.editor.set_status(string.format(
        "LSP: definition at %s:%d:%d",
        path, first.line + 1, first.col + 1))
    end
  end)
end

function pmacs.lsp.find_references()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  pmacs.references.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_references(rec.server, rec.uri, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local locs = pmacs.references.locations(rec.server, rec.uri)
    if not locs or #locs == 0 then
      pmacs.editor.set_status("LSP: no references found")
      return
    end
    -- v1 surfaces a modeline summary (count + first hit); a
    -- references list buffer is future UX work, like the hover panel.
    local first = locs[1]
    pmacs.editor.set_status(string.format(
      "LSP: %d reference%s; first at %s:%d:%d",
      #locs, (#locs == 1 and "" or "s"),
      first.uri, first.line + 1, first.col + 1))
  end)
end

function pmacs.lsp.document_symbols()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  pmacs.document_symbol.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_document_symbol(rec.server, rec.uri):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local syms = pmacs.document_symbol.symbols(rec.server, rec.uri)
    if not syms or #syms == 0 then
      pmacs.editor.set_status("LSP: no symbols")
      return
    end
    -- v1 modeline summary (count + first symbol); a structured
    -- outline buffer driven off this store is future UX work, like
    -- the references list and hover panel.
    local first = syms[1]
    pmacs.editor.set_status(string.format(
      "LSP: %d symbol%s; first '%s' at %d:%d",
      #syms, (#syms == 1 and "" or "s"),
      first.name, first.line + 1, first.col + 1))
  end)
end

-- T M4.5 — inlay hints for the whole buffer. Requests over a range
-- spanning the document, stores the parsed hints, and surfaces a
-- modeline summary (count + first). Inline virtual-text rendering is
-- a separate milestone (the cell-overlay model does not yet reflow
-- real glyphs around inserted columns); a render layer subscribes to
-- the same `pmacs.inlay_hint` store when it lands — same staged
-- approach as the references list / hover panel.
function pmacs.lsp.inlay_hints()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  -- Whole-document range: (0,0) .. exact document end. Some servers,
  -- including rust-analyzer, reject one-past or otherwise over-wide
  -- line numbers instead of clamping.
  local text = active_buffer_text()
  local end_line, end_col = document_end_position(text)
  pmacs.inlay_hint.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_inlay_hint(
        rec.server, rec.uri, 0, 0, end_line, end_col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local hints = pmacs.inlay_hint.hints(rec.server, rec.uri)
    if not hints or #hints == 0 then
      pmacs.editor.set_status("LSP: no inlay hints")
      return
    end
    local first = hints[1]
    pmacs.editor.set_status(string.format(
      "LSP: %d inlay hint%s; first '%s' at %d:%d",
      #hints, (#hints == 1 and "" or "s"),
      first.label, first.line + 1, first.col + 1))
  end)
end

-- T M4.5 — semantic tokens for the whole buffer. Incremental: if a
-- prior result id exists for this buffer, request a
-- `/full/delta` against it (the store keeps the raw int stream to
-- splice on); otherwise a `/full` pull. Either way the store ends
-- with the complete token set + a fresh result id, and a modeline
-- summary (count + first token's type, resolved through the legend)
-- is shown. Data only: wiring LSP tokens into styling (a second
-- authority alongside tree-sitter) is a separate rendering
-- milestone — a render layer subscribes to the same
-- `pmacs.semantic_tokens` store when it lands.
--
-- `pmacs.lsp.request_semantic_tokens_range` is also exposed (no
-- default command) for a future viewport-aware caller: the bundle
-- has no on-screen-range source, so a "range" command here would
-- just duplicate `/full`.
function pmacs.lsp.semantic_tokens()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  -- Don't clear: a delta splices against the retained raw stream.
  local prev = pmacs.semantic_tokens.result_id(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      if prev then
        pmacs.lsp.request_semantic_tokens_delta(
          rec.server, rec.uri, prev):await()
      else
        pmacs.lsp.request_semantic_tokens(rec.server, rec.uri):await()
      end
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local toks = pmacs.semantic_tokens.tokens(rec.server, rec.uri)
    if not toks or #toks == 0 then
      pmacs.editor.set_status("LSP: no semantic tokens")
      return
    end
    local first = toks[1]
    -- Resolve the type index through the legend (0-based index ->
    -- 1-based Lua array); fall back to the raw index if no legend.
    local legend = pmacs.semantic_tokens.legend(rec.server)
    local tname = legend and legend.token_types
      and legend.token_types[first.token_type + 1]
      or tostring(first.token_type)
    pmacs.editor.set_status(string.format(
      "LSP: %d semantic token%s%s; first '%s' at %d:%d",
      #toks, (#toks == 1 and "" or "s"),
      (prev and " (delta)" or ""),
      tname, first.line + 1, first.start + 1))
  end)
end

function pmacs.lsp.format_buffer()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  pmacs.formatting.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_formatting(rec.server, rec.uri, 4, true):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local edits = pmacs.formatting.edits(rec.server, rec.uri)
    if not edits or #edits == 0 then
      pmacs.editor.set_status("LSP: no formatting edits")
      return
    end
    local n = apply_text_edits(edits)
    pmacs.editor.set_status(string.format("LSP: applied %d edits", n))
  end)
end

-- T M4.5 — rename the symbol under the cursor.
--
-- When the server advertises `renameProvider.prepareProvider`, a
-- `textDocument/prepareRename` round-trip runs first: it gates the
-- prompt (a `null` result means "not renameable here" — abort with a
-- status, never open the prompt) and pre-fills the placeholder the
-- server suggests. Servers that don't advertise prepare (or advertise
-- `renameProvider: true`) skip straight to the prompt — the original
-- L2 behavior, unchanged. The cursor position is captured *before*
-- the prompt opens so the request still targets the original symbol
-- even though the minibuffer session moved focus.
function pmacs.lsp.rename()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()

  local function open_prompt(initial)
    pmacs.minibuffer.read {
      prompt = "Rename symbol to: ",
      initial = initial,
      on_cancel = function()
        pmacs.editor.set_status("LSP: rename cancelled")
      end,
      on_accept = function(new_name)
        if not new_name or new_name == "" then
          pmacs.editor.set_status("LSP: rename needs a new name")
          return
        end
        pmacs.rename.clear(rec.server, rec.uri)
        pmacs.async(function()
          local ok, err = pcall(function()
            pmacs.lsp.request_rename(rec.server, rec.uri, line, col, new_name):await()
          end)
          if not ok then
            pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
            return
          end
          local ops = pmacs.rename.ops(rec.server, rec.uri)
          if not ops or #ops == 0 then
            pmacs.editor.set_status("LSP: rename produced no edits")
            return
          end
          local n, files, res = apply_workspace_edit(ops)
          if not n then
            -- Preflight rejected it; nothing was mutated.
            pmacs.editor.set_status("LSP: rename aborted: " .. tostring(files))
            return
          end
          local msg = string.format(
            "LSP: renamed — %d edit%s across %d file%s",
            n, (n == 1 and "" or "s"),
            files, (files == 1 and "" or "s"))
          if res and res > 0 then
            msg = msg .. string.format(
              " (+%d file op%s)", res, (res == 1 and "" or "s"))
          end
          pmacs.editor.set_status(msg)
        end)
      end,
    }
  end

  -- Gate on the server advertising prepareRename. `renameProvider`
  -- is `boolean | { prepareProvider?: boolean }`.
  local caps = pmacs.lsp.capabilities(rec.server)
  local rp = caps and caps.renameProvider
  if not (type(rp) == "table" and rp.prepareProvider == true) then
    open_prompt(nil)
    return
  end

  pmacs.prepare_rename.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_prepare_rename(rec.server, rec.uri, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local pr = pmacs.prepare_rename.result(rec.server, rec.uri)
    if not pr or not pr.allowed then
      pmacs.editor.set_status("LSP: cannot rename here")
      return
    end
    open_prompt(pr.placeholder)
  end)
end

-- T M4.5 L3 — code actions at the cursor. Requests the actions,
-- then applies the first one: an inline `edit` goes through the
-- shared WorkspaceEdit applier; a `command` is dispatched via
-- `workspace/executeCommand` (after which the server usually drives
-- the change with a server→client `workspace/applyEdit`, handled by
-- the pump installed below). A selection UI over multiple actions is
-- future UX work, like the references list and hover panel — v1
-- acts on the first and reports how many were offered.
function pmacs.lsp.code_actions()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  pmacs.code_action.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_code_action(
        rec.server, rec.uri, line, col, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local acts = pmacs.code_action.actions(rec.server, rec.uri)
    if not acts or #acts == 0 then
      pmacs.editor.set_status("LSP: no code actions")
      return
    end
    local first = acts[1]
    local bits = {}
    if first.has_edit then
      local n, files, res = apply_workspace_edit(first.edit)
      if not n then
        pmacs.editor.set_status("LSP: code action aborted: " .. tostring(files))
        return
      end
      local b = string.format("%d edit(s) / %d file(s)", n, files)
      if res and res > 0 then b = b .. string.format(" / %d file op(s)", res) end
      table.insert(bits, b)
    end
    if first.command then
      local ok2, cerr = pcall(function()
        pmacs.lsp.request_execute_command(
          rec.server, first.command.command, first.command.arguments):await()
      end)
      if not ok2 then
        pmacs.editor.set_status("LSP: command failed: " .. lsp_await_error(cerr))
        return
      end
      table.insert(bits, "ran '" .. first.command.command .. "'")
    end
    local detail = (#bits > 0) and (" — " .. table.concat(bits, ", ")) or ""
    pmacs.editor.set_status(string.format(
      "LSP: code action '%s'%s (%d available)",
      first.title, detail, #acts))
  end)
end

function pmacs.lsp.hover_at_cursor()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  pmacs.hover.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_hover(rec.server, rec.uri, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local hover = pmacs.hover.current(rec.server, rec.uri)
    if not hover then
      pmacs.editor.set_status("LSP: no hover info")
      return
    end
    -- Surface the first line of the hover body in the modeline. The
    -- popup view subscribes to the same store; a panel can wire in
    -- here when the keybinding is meant to surface one.
    local first = (hover.contents or ""):match("^[^\n]*") or ""
    pmacs.editor.set_status(first ~= "" and ("LSP: " .. first) or "LSP: hover empty")
  end)
end

function pmacs.lsp.signature_help_at_cursor()
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("LSP: no server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  pmacs.signature.clear(rec.server, rec.uri)
  pmacs.async(function()
    local ok, err = pcall(function()
      pmacs.lsp.request_signature_help(rec.server, rec.uri, line, col):await()
    end)
    if not ok then
      pmacs.editor.set_status("LSP: " .. lsp_await_error(err))
      return
    end
    local help = pmacs.signature.current(rec.server, rec.uri)
    if not help or not help.signatures or #help.signatures == 0 then
      pmacs.editor.set_status("LSP: no signature help")
      return
    end
    local active = help.signatures[(help.active_signature or 0) + 1]
    pmacs.editor.set_status(active and ("LSP: " .. active.label) or "LSP: signature unknown")
  end)
end

-- Default commands + keymap entries --------------------------------------

pmacs.command.define {
  name = "lsp.go-to-definition",
  description = "Jump to the definition of the symbol under the cursor (LSP).",
  fn = pmacs.lsp.go_to_definition,
}

pmacs.command.define {
  name = "lsp.format-buffer",
  description = "Format the active buffer through the attached LSP server.",
  fn = pmacs.lsp.format_buffer,
}

pmacs.command.define {
  name = "lsp.hover",
  description = "Surface the hover documentation for the symbol under the cursor.",
  fn = pmacs.lsp.hover_at_cursor,
}

pmacs.command.define {
  name = "lsp.signature-help",
  description = "Surface the signature of the function call at the cursor.",
  fn = pmacs.lsp.signature_help_at_cursor,
}

pmacs.command.define {
  name = "lsp.find-references",
  description = "Find references to the symbol under the cursor (LSP).",
  fn = pmacs.lsp.find_references,
}

pmacs.command.define {
  name = "lsp.document-symbols",
  description = "List the symbols (outline) of the active buffer (LSP).",
  fn = pmacs.lsp.document_symbols,
}

pmacs.command.define {
  name = "lsp.rename",
  description = "Rename the symbol under the cursor across the workspace (LSP).",
  fn = pmacs.lsp.rename,
}

pmacs.command.define {
  name = "lsp.code-actions",
  description = "Apply a code action for the symbol/range under the cursor (LSP).",
  fn = pmacs.lsp.code_actions,
}

pmacs.command.define {
  name = "lsp.inlay-hints",
  description = "Fetch inlay hints (inferred types / parameter names) for the buffer (LSP).",
  fn = pmacs.lsp.inlay_hints,
}

pmacs.command.define {
  name = "lsp.semantic-tokens",
  description = "Fetch semantic tokens (type-aware classification) for the buffer (LSP).",
  fn = pmacs.lsp.semantic_tokens,
}

-- T M4.5 L1 — unwind the cross-file jump ring. Pairs with the
-- `pmacs.editor.push_jump()` every navigation action records before
-- it moves the cursor.
pmacs.command.define {
  name = "lsp.jump-back",
  description = "Return to the location before the last LSP navigation jump.",
  fn = function()
    if not pmacs.editor.jump_back() then
      pmacs.editor.set_status("LSP: jump ring empty")
    end
  end,
}

-- Default chords. M-. follows the cross-editor convention for
-- go-to-definition; the others sit on `C-c` to keep printable letters
-- self-inserting. The user can override or unbind any of these from
-- init.lua.
pmacs.keymap.bind { scope = "global", sequence = "M-.",   command = "lsp.go-to-definition" }
pmacs.keymap.bind { scope = "global", sequence = "M-?",   command = "lsp.find-references" }
pmacs.keymap.bind { scope = "global", sequence = "M-,",   command = "lsp.jump-back" }
pmacs.keymap.bind { scope = "global", sequence = "C-c o", command = "lsp.document-symbols" }
pmacs.keymap.bind { scope = "global", sequence = "C-c r", command = "lsp.rename" }
pmacs.keymap.bind { scope = "global", sequence = "C-c a", command = "lsp.code-actions" }
pmacs.keymap.bind { scope = "global", sequence = "C-c i", command = "lsp.inlay-hints" }
pmacs.keymap.bind { scope = "global", sequence = "C-c y", command = "lsp.semantic-tokens" }
pmacs.keymap.bind { scope = "global", sequence = "C-c h", command = "lsp.hover" }
pmacs.keymap.bind { scope = "global", sequence = "C-c s", command = "lsp.signature-help" }
pmacs.keymap.bind { scope = "global", sequence = "C-c f", command = "lsp.format-buffer" }

-- Diagnostic navigation (task #23, M4.6 surface) -----------------------------
--
-- Emacs's `M-g n` / `M-g p` jump between compile/next-error locations. We
-- reuse the chord for LSP diagnostics: walk the diag store for the active
-- buffer's URI and move the cursor to the next/previous diagnostic. Wraps
-- around (`pmacs.diag.next`'s default), so repeated taps cycle.
local function navigate_diagnostic(direction)
  local rec = attached_for_active()
  if not rec then
    pmacs.editor.set_status("diag: no LSP server for active buffer")
    return
  end
  local line = pmacs.editor.cursor_line()
  local col = pmacs.editor.cursor_col()
  local found
  if direction == "next" then
    found = pmacs.diag.next(rec.uri, line, col)
  else
    found = pmacs.diag.previous(rec.uri, line, col)
  end
  if not found then
    pmacs.editor.set_status("diag: no diagnostics in buffer")
    return
  end
  pmacs.editor.push_jump()
  move_active_cursor_to(found.start_line, found.start_col)
  pmacs.editor.set_status(string.format("diag (%s): %s",
    found.severity or "?", found.message or ""))
end

pmacs.command.define {
  name = "diag.next",
  description = "Jump to the next diagnostic in the active buffer (wraps).",
  fn = function() navigate_diagnostic("next") end,
}

pmacs.command.define {
  name = "diag.previous",
  description = "Jump to the previous diagnostic in the active buffer (wraps).",
  fn = function() navigate_diagnostic("previous") end,
}

pmacs.keymap.bind { scope = "global", sequence = "M-g n", command = "diag.next" }
pmacs.keymap.bind { scope = "global", sequence = "M-g p", command = "diag.previous" }
