-- lean_input.lua --- the Lean 4 Unicode input method (Arc 8 Stage 4b).
--
-- Typing `\alpha` gives `α`; `\<>` gives `⟨⟩` with the point between.
-- The table is vendored in lean_abbrev.lua, generated from
-- vscode-lean4 — see that file's header and Q#LN11.
--
-- This is a typed-edit consumer (Stage 4a, Q#LN10) registered AHEAD of
-- auto-pairing at priority 50. The ordering is load-bearing, not
-- cosmetic: 64 abbreviation keys contain a character in the `lean4`
-- pair set (`\[[]]` → `⟦⟧`, `\{{}}` → `⦃⦄`), so with pairing first,
-- typing `\[` would insert `[]` with the point between and corrupt the
-- pending key to `\[]` before the second `[` arrives — `\[[]]` becomes
-- unreachable. Priority, not load order, is what decides this; that is
-- the whole reason Stage 4a exists.
--
-- The consumer therefore claims every keystroke that EXTENDS an open
-- pending abbreviation, not merely one that completes an expansion. A
-- consumer that claimed only completed expansions would hand each
-- intermediate `[` to pairing, which is the same corruption by a
-- different route. "Claimed" means the chain stops, not that an edit
-- was made (Q#LN22).
--
-- UNDO IS CROSS-PEER-DEGRADED, and this is accepted rather than papered
-- over (Q#LN21). `classify_key` (src/optimistic.rs) returns `Insert(c)`
-- for `\` and for every ASCII letter — only the nine built-in pair
-- chars are excluded — so on a CRDT frontend `\alpha` arrives as six
-- SOURCE-peer optimistic inserts while the expansion is a single
-- DAEMON-peer replace spanning all six. Undo across that boundary is
-- not chronologically arbitrated. This is the same defect Q#LN6 already
-- accepts for `⟨⟩`, one order of magnitude wider: it is every
-- abbreviation the user types, not a few brackets. The general fix is
-- chronological cross-peer undo arbitration, named substrate work.
-- `set_round_trip_input` would fix it and is rejected — it also makes
-- `dispatch_idle` report false, so RET would stop inserting a newline.
--
-- Framing: docs/lean4-mode-framing.md Q#LN11, Q#LN21, Q#LN22.

pmacs.lean_input = pmacs.lean_input or {}

local ed = pmacs.editor

local LEADER = "\\"
local CURSOR = "$CURSOR"

pmacs.config.define {
  name = "lean.abbrev",
  description = "Expand \\-prefixed abbreviations into Unicode symbols in Lean 4 buffers.",
  type = "boolean",
  default = true,
  mutability = "live",
}

-- ---------------------------------------------------------------------
-- The table, and the two indexes derived from it at load time
-- ---------------------------------------------------------------------

-- `best[p]` is the symbol for the shortest key having `p` as a prefix,
-- ties broken by the key's position in the vendored sequence. Both
-- halves matter: 101 prefixes have equal-shortest candidates that
-- resolve to DIFFERENT symbols (`f` → `‹` from `f<`, not `›` from
-- `f>`), and the sequence's order is the only place that tie is
-- recorded. `pairs` over a map-shaped table could not express it.
--
-- `eager[k]` marks the 1,550 keys that are complete and have no longer
-- key extending them — the ones that expand the moment they are typed,
-- with no terminator. `to` is NOT one of them (`top`, `to0`, `toa`),
-- which is exactly the case that reads as eager until the table is
-- consulted.
local best, eager = {}, {}

do
  local seq = pmacs.lean_abbrev
  if type(seq) ~= "table" then seq = {} end
  local extended = {}
  for i = 1, #seq do
    local entry = seq[i]
    local key, symbol = entry[1], entry[2]
    -- Walk every prefix of the key, including the key itself. Iterating
    -- the sequence in order and only overwriting on a STRICTLY shorter
    -- key is what makes the source-order tiebreak fall out: an equal
    -- length arriving later loses to the one already recorded.
    for n = 1, #key do
      local p = key:sub(1, n)
      local cur = best[p]
      if cur == nil or #key < cur.len then
        best[p] = { symbol = symbol, len = #key }
      end
      if n < #key then extended[p] = true end
    end
  end
  for i = 1, #seq do
    local key = seq[i][1]
    if not extended[key] then eager[key] = true end
  end
end

-- Test seam (leading underscore = not stable API). Acceptance 45g reads
-- these to pin self-consistency properties a corrupt emit would break —
-- it cannot diff against `abbreviations.json`, which is not shipped.
function pmacs.lean_input._resolve(text)
  local hit = best[text]
  return hit and hit.symbol or nil
end

function pmacs.lean_input._is_eager(key)
  return eager[key] == true
end

-- ---------------------------------------------------------------------
-- Pending state: one record per FRONTEND (Q#LN22)
-- ---------------------------------------------------------------------

-- Keyed by frontend id, with the buffer stored inside and compared by
-- value. Q#LN22 specifies the key as `(frontend, buffer)`; a per-
-- frontend slot is equivalent here and avoids inventing a scalar
-- buffer key (`BufferId`'s inner value is deliberately private, R22).
-- The generality a two-level map would add is unreachable: a frontend
-- has one point, and `buffer.after-switch` clears that frontend's slot,
-- so no frontend can hold pending state in a buffer it is not in.
--
-- Per-frontend rather than per-buffer is NOT a refinement — a buffer-
-- keyed table lets either frontend consume or discard the other's
-- half-typed abbreviation in a shared buffer, which is the ordinary
-- TUI-plus-GPU configuration this project ships.
local pending = {}

-- Expansions the chain consumer decided on but did NOT perform, keyed
-- the same way. See `run_deferred` below for why they wait.
local deferred = {}

local function frontend_id()
  local ok, id = pcall(function() return pmacs.frontend.id() end)
  if ok then return id end
  return nil
end

-- Is `rec` a typed edit that continues `p` exactly? Conservative by
-- construction (Q#LN22): abandonment is LAZY because pmacs has no
-- cursor-motion hook, so every guard that would have been checked at
-- the moment the user left is checked here instead, at the next typed
-- edit.
local function still_valid(p, rec, buf)
  if p.buffer ~= rec.buffer or p.window ~= rec.window then return false end
  -- The point must still be at the end of the pending span: the leader,
  -- plus what has been typed into it, plus the character that just
  -- landed.
  if rec.effective_start ~= p.start_offset + 1 + #p.text then return false end
  -- Exactly one edit since this frontend last extended the pending
  -- abbreviation — the one being processed now. Deliberately strict
  -- across frontends: `revision()` is BUFFER-GLOBAL, so a peer editing
  -- the shared buffer invalidates this record even though it edited
  -- elsewhere. Keeping it alive would mean translating and validating
  -- the span through arbitrary peer edits, substrate Stage 4b does not
  -- add.
  local ok, rev = pcall(function() return buf:revision() end)
  if not ok or rev ~= p.expected_revision + 1 then return false end
  return true
end

-- ---------------------------------------------------------------------
-- Expansion
-- ---------------------------------------------------------------------

-- Right-gravity translation of `pos` through the effective edit —
-- pair.lua's shape, for the same reason: the point sits AFTER the
-- replaced span (on the terminator, or on a closer pairing inserted)
-- and has to move with it.
local function translate(pos, estart, estop, einserted)
  if pos < estart then return pos end
  if pos > estop then return pos - (estop - estart) + einserted end
  return estart + einserted
end

-- Replace the pending span (leader + typed text) with `symbol`.
--
-- The span deliberately STOPS BEFORE the terminator. Including the
-- terminator would make the expansion and the terminator one edit, but
-- it would also swallow whatever auto-pairing did with that terminator
-- — and a pair character is a legal terminator (`\alp(`). One undo
-- restores the same text either way, because the terminator was its own
-- insert to begin with.
--
-- ONE `buf:replace`: one undo step, one CRDT op, one effective-edit
-- verification. A rejection drops the pending state and does not retry,
-- the same discipline as comment.lua's Q#CT5 and pair.lua.
local function expand(buf, start, span_end, symbol)
  local cursor_at = symbol:find(CURSOR, 1, true)
  local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol

  -- The context to compare against AFTER the edit. A buffer intercept
  -- may switch window or buffer while the replace runs; the point in
  -- whatever it switched to is not ours to move.
  local win0 = pmacs.window.current()
  local point0 = ed.cursor()

  local ok, estart, estop, einserted = pcall(function()
    return buf:replace(start, span_end, text)
  end)
  if not ok then
    ed.set_status("lean abbreviation rejected by buffer intercept")
    return nil
  end
  if estart ~= start or estop ~= span_end or einserted ~= #text then
    ed.set_status("lean abbreviation altered by buffer intercept")
    return nil
  end

  -- The point MUST be placed explicitly. Unlike pairing's at-cursor
  -- insert, this replace SHRINKS the buffer — `\alpha` (6 bytes)
  -- becomes `α` (2) — and a point left at the pre-edit offset is past
  -- the new end. Every later self-insert is then silently rejected and
  -- the editor looks dead. There is no daemon re-grounding that covers
  -- this; that only holds for an edit that lands at the cursor.
  --
  -- Context-guarded exactly as pair.lua's `repair_cursor` is: if the
  -- intercept switched us elsewhere, `goto_byte` would move the point
  -- of a buffer that has nothing to do with this expansion.
  if pmacs.window.current() == win0 and pmacs.window.buffer() == buf then
    if cursor_at then
      ed.goto_byte(start + cursor_at - 1)
    else
      ed.goto_byte(translate(point0, estart, estop, einserted))
    end
  end
  return start + #text
end

-- ---------------------------------------------------------------------
-- The consumer
-- ---------------------------------------------------------------------

-- Chain invocations not yet matched by a `run_deferred`.
--
-- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly
-- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`,
-- and a nested run re-enters every subscriber — including this module's
-- deferred-expansion subscriber, while the OUTER chain is still walking
-- its consumer list and pairing has not yet seen the terminator. A
-- nested run that performed the expansion would reproduce exactly the
-- bug deferring exists to fix: pairing resumes afterwards holding a
-- record the replace has invalidated, declines, and the closer is lost.
--
-- Counting has to happen INSIDE the chain and BEFORE any consumer that
-- might start a nested fan-out. A subscriber registered alongside
-- `run_deferred` is too late — the whole nested fan-out completes
-- inside the outer chain's subscriber, before either of them runs. And
-- counting in the expander itself is not enough: a lower-priority
-- consumer may CLAIM and stop the chain before the expander is
-- reached, so a nested pass would go uncounted while its
-- `run_deferred` still ran (round 11's fix, round 12's defect).
--
-- Hence a separate no-op consumer at the minimum priority, which runs
-- first in every chain invocation that reaches any consumer at all.
-- Its guarantee is exactly the ordering contract the chain already
-- rests on, and it degrades safely: the only thing that can skip it is
-- a claim ahead of it, which skips the expander too, so nothing is
-- queued in that fan-out either.
local depth = 0

local function count_fan_out()
  depth = depth + 1
  return false
end

local function on_typed_edit(rec)
  local fid = frontend_id()
  if fid == nil then return false end

  -- A fan-out carrying no record is still information: a paste,
  -- programmatic edit or replicated op landed, so whatever this
  -- frontend had pending no longer describes the buffer. Drop it and
  -- decline — this is why the chain calls consumers with nil rather
  -- than skipping them (Q#LN10).
  if not rec then
    pending[fid] = nil
    return false
  end
  if not (ed.this_command and ed.this_command() == "buffer.self-insert") then
    pending[fid] = nil
    return false
  end

  -- Both gates resolve against the SOURCE buffer of the typed edit, not
  -- the active one — a context-switching command may have replaced it
  -- by callback time (pair.lua round 2, finding 2).
  if not pmacs.config.get("lean.abbrev", rec.buffer) then
    pending[fid] = nil
    return false
  end
  local lang
  if pmacs.lsp and pmacs.lsp.buffer_language then
    local ok, l = pcall(pmacs.lsp.buffer_language, rec.buffer)
    if ok then lang = l end
  end
  if lang ~= "lean4" then
    -- No pending abbreviation is ever OPENED outside a `lean4` buffer:
    -- `\` in Rust is an ordinary character and `\[` there still pairs.
    pending[fid] = nil
    return false
  end

  local buf = pmacs.window.buffer()
  if not buf or buf ~= rec.buffer or pmacs.window.current() ~= rec.window then
    pending[fid] = nil
    return false
  end
  -- Fail closed on a transformed source self-insert, as pairing does:
  -- expanding on top of a relocated or rewritten character compounds
  -- the intercept's result.
  if not rec.clean then
    pending[fid] = nil
    return false
  end
  -- ...and on a source edit whose context is no longer current. The
  -- buffer and window matching is not enough: a redefined self-insert
  -- can insert the character and THEN move the point, and expanding
  -- over a span the user has left teleports them back into it. Pairing
  -- makes the same three-part check for the same reason.
  if ed.cursor() ~= rec.post_cursor then
    pending[fid] = nil
    return false
  end

  local revision
  do
    local ok, rev = pcall(function() return buf:revision() end)
    if not ok then
      pending[fid] = nil
      return false
    end
    revision = rev
  end

  local p = pending[fid]
  if p and not still_valid(p, rec, buf) then
    p = nil
    pending[fid] = nil
  end

  local ch = rec.char

  -- No pending abbreviation: only the leader opens one.
  if not p then
    if ch == LEADER then
      pending[fid] = {
        buffer = rec.buffer,
        window = rec.window,
        start_offset = rec.effective_start,
        text = "",
        expected_revision = revision,
      }
      -- Claimed: the leader belongs to the abbreviation, and pairing
      -- has no interest in it either way.
      return true
    end
    return false
  end

  -- Pending: does any key still have `text .. ch` as a prefix?
  local extended = p.text .. ch
  if best[extended] then
    p.text = extended
    p.expected_revision = revision
    if eager[extended] then
      pending[fid] = nil
      deferred[fid] = {
        buffer = rec.buffer,
        window = rec.window,
        start_offset = p.start_offset,
        text = extended,
        symbol = best[extended].symbol,
        re_arm = false,
      }
    end
    -- Claimed either way: an extension that has not yet completed must
    -- NOT reach auto-pairing (`\[` in `\[[]]`), and a completing one is
    -- part of the abbreviation, not a character pairing should react to.
    return true
  end

  -- `ch` does not extend the abbreviation: it TERMINATES it, and a
  -- terminator is an ordinary character that auto-pairing is entitled
  -- to react to (`\alp(` must give `α()`). So the expansion is
  -- DEFERRED to the subscriber below and this returns false, leaving
  -- pairing a record whose offsets still describe the buffer.
  --
  -- Expanding here and returning false would not do: the replace makes
  -- pairing's copy of the record stale, so pairing declines and the
  -- closer is silently lost. Expanding here and returning true is
  -- worse — it is what shipped in the first revision of this file, and
  -- it makes every pair-character terminator silently unpaired.
  pending[fid] = nil
  if best[p.text] and #p.text > 0 then
    deferred[fid] = {
      buffer = rec.buffer,
      window = rec.window,
      start_offset = p.start_offset,
      text = p.text,
      symbol = best[p.text].symbol,
      -- A terminating `\` re-arms as a NEW leader at its own position
      -- (`\al\to` → `∀→`). Upstream gets this from `processChange`,
      -- where a finished abbreviation reports `isAffected = false` and
      -- so does not suppress the new-leader branch. This is NOT the
      -- `\\` case: there the pending text is empty, `\` EXTENDS, and
      -- the result is one literal backslash with nothing left open.
      re_arm = ch == LEADER,
    }
  elseif ch == LEADER then
    -- Nothing to expand, but the leader still opens a fresh
    -- abbreviation where it landed.
    pending[fid] = {
      buffer = rec.buffer,
      window = rec.window,
      start_offset = rec.effective_start,
      text = "",
      expected_revision = revision,
    }
    return true
  end

  return false
end

-- The deferred expansion, on its own `buffer.after-edit` subscriber.
--
-- It runs AFTER the whole typed-edit chain — this chunk loads after
-- typed_edit.lua, and hook callbacks run in registration order — so
-- auto-pairing has already reacted to the terminator by the time the
-- expansion rewrites the text in front of it. Pairing's closer lands
-- after the terminator, outside the replaced span, so it survives.
--
-- It must also run BEFORE lsp.lua's subscriber (Q#AP7): that one
-- flushes `didChange` synchronously on the signature-trigger path, and
-- a server told about `\alp ` instead of `α ` stays wrong until the
-- next edit. This chunk loads before lsp.lua for exactly that reason.
--
-- A claim by ANY chain consumer stops the chain but not this — which
-- is the point. Pairing claims the terminator it reacts to.
local function run_deferred()
  -- Match off this fan-out's chain invocation. `> 1` means the outer
  -- chain is still mid-list — pairing has not had the terminator yet —
  -- so the queued expansion stays queued for the outer pass. The clamp
  -- keeps this honest if a claim beat the counting consumer, in which
  -- case nothing was queued in that fan-out either.
  local level = depth
  if depth > 0 then depth = depth - 1 end
  if level > 1 then return end

  local fid = frontend_id()
  if fid == nil then return end
  local d = deferred[fid]
  deferred[fid] = nil
  if not d then return end

  local buf = pmacs.window.buffer()
  if not buf or buf ~= d.buffer or pmacs.window.current() ~= d.window then
    return
  end

  -- The span must still hold exactly what was typed into it. Pairing
  -- only edits at the point, which is past this span, so in practice
  -- this holds; a buffer intercept is not obliged to be so polite.
  local span_end = d.start_offset + 1 + #d.text
  local ok, actual = pcall(function()
    return buf:slice(d.start_offset, span_end)
  end)
  if not ok or actual ~= LEADER .. d.text then return end

  local after = expand(buf, d.start_offset, span_end, d.symbol)
  if after and d.re_arm then
    local rev_ok, rev = pcall(function() return buf:revision() end)
    if rev_ok then
      pending[fid] = {
        buffer = d.buffer,
        window = d.window,
        start_offset = after,
        text = "",
        expected_revision = rev,
      }
    end
  end
end

-- Q#KR11's seam: a detached frontend's pending state must not outlive
-- it. Ids are monotonic, so this table would otherwise grow for the
-- life of the session.
pmacs.hook.add("frontend.detached", function(fid)
  pending[fid] = nil
  deferred[fid] = nil
end)

pmacs.hook.add("buffer.after-edit", run_deferred)

-- `buffer.after-switch` fires with NO arguments, so it cannot say whose
-- switch it was. The acting frontend is the one that produced the most
-- recent dispatched input event, which is what `pmacs.frontend.id()`
-- reports at callback time. Clearing every entry instead would let one
-- frontend's navigation discard another's half-typed abbreviation.
pmacs.hook.add("buffer.after-switch", function()
  local fid = frontend_id()
  if fid ~= nil then pending[fid] = nil end
end)

-- Runs first in every chain invocation that reaches a consumer at all,
-- which is what makes the nesting count trustworthy — see `depth`. It
-- declines, always: it observes, it does not participate.
pmacs.typed_edit.add_consumer {
  name = "lean-abbrev-fan-out-counter",
  priority = -2147483648,
  fn = count_fan_out,
}

pmacs.typed_edit.add_consumer {
  name = "lean-abbrev",
  priority = 50,
  fn = on_typed_edit,
}
