-- pair.lua --- auto-pairing (Arc 2).
--
-- Typing `(` gives `()` with the cursor between; typing `)` when the
-- next char is already `)` steps over it instead of doubling it. The
-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays
-- a genuine single-codepoint self-insert — the classification
-- signature help depends on — and this hook inserts (or swallows) the
-- closer as a second edit. Provenance is the exact one-shot typed-edit
-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text
-- inference: pastes, programmatic edits, manual hook runs, and a stale
-- `this_command` have no record and never pair, and a transformed,
-- relocated, or context-switching source self-insert fails closed.
--
-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook
-- execution order, and lsp.lua's after-edit callback synchronously
-- flushes didChange on the signature-trigger path — the closer must
-- already be in the buffer when that callback runs. Everything under
-- `pmacs.lsp` is therefore looked up lazily at callback time.
--
-- Framing: docs/auto-pairing-framing.md.

pmacs.pair = pmacs.pair or {}

local ed = pmacs.editor

-- Language → array of pair strings (opener codepoint followed by
-- closer codepoint), plus the `default` entry used when the language
-- is unknown or has no entry — pairing is useful in scratch buffers
-- (Q#AP2). Public and user-extensible, like `pmacs.comment.strings`:
--   pmacs.pair.sets.rust = { "()", "[]", "{}", '""', "''" }
-- Conservative defaults: no `'` (prose apostrophes, Rust lifetimes,
-- char literals), no backtick, outside the languages that want them.
-- NOTE (Q#AP1): only the nine built-in chars `()[]{}"'` and backtick
-- are excluded from the frontends' optimistic classifiers. A
-- user-added pair char beyond those still pairs, but arrives
-- optimistically: its opener is a source-peer op and the closer a
-- daemon-peer op, so its undo is cross-peer-degraded (documented
-- limitation; the general fix is chronological cross-peer undo
-- arbitration, named substrate work).
pmacs.pair.sets = {
  default = { "()", "[]", "{}", '""' },
  python = { "()", "[]", "{}", '""', "''" },
  lua = { "()", "[]", "{}", '""', "''" },
  javascript = { "()", "[]", "{}", '""', "''", "``" },
  typescript = { "()", "[]", "{}", '""', "''", "``" },
  javascriptreact = { "()", "[]", "{}", '""', "''", "``" },
  typescriptreact = { "()", "[]", "{}", '""', "''", "``" },
  markdown = { "()", "[]", "{}", '""', "``" },
  sh = { "()", "[]", "{}", '""', "''" },
  bash = { "()", "[]", "{}", '""', "''" },
}

-- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil
-- for anything ill-formed (Unicode 15, Table 3-7): continuation-byte
-- shapes are checked on EVERY trailing byte, and the narrowed
-- second-byte ranges exclude overlong encodings (C0/C1 leads,
-- E0 80–9F, F0 80–8F), UTF-16 surrogates (ED A0–BF), and codepoints
-- beyond U+10FFFF (F5+ leads, F4 90+). Length-from-lead-byte alone
-- accepted "(\xC2x" as two "codepoints" (PR #110 round 2, finding 1).
local function utf8_seq_len(s, i)
  local b1 = s:byte(i)
  if not b1 then return nil end
  if b1 < 0x80 then return 1 end
  if b1 < 0xC2 or b1 > 0xF4 then return nil end
  local b2 = s:byte(i + 1)
  if not b2 or b2 < 0x80 or b2 > 0xBF then return nil end
  if b1 < 0xE0 then return 2 end
  if b1 == 0xE0 and b2 < 0xA0 then return nil end
  if b1 == 0xED and b2 > 0x9F then return nil end
  if b1 == 0xF0 and b2 < 0x90 then return nil end
  if b1 == 0xF4 and b2 > 0x8F then return nil end
  local b3 = s:byte(i + 2)
  if not b3 or b3 < 0x80 or b3 > 0xBF then return nil end
  if b1 < 0xF0 then return 3 end
  local b4 = s:byte(i + 3)
  if not b4 or b4 < 0x80 or b4 > 0xBF then return nil end
  return 4
end

-- The first full UTF-8 codepoint starting at byte `pos`, as a string;
-- nil at end-of-buffer. Bytes that do not begin a well-formed
-- sequence (malformed file content, a truncated sequence at EOF)
-- yield the single raw byte instead: it matches neither whitespace
-- nor any validated closer, so the predicate conservatively treats
-- junk like a word character — never like EOL, which nil would mean.
local function char_at(buf, pos)
  local len = buf:len()
  if pos >= len then return nil end
  local to = math.min(pos + 4, len)
  local ok, s = pcall(function() return buf:slice(pos, to) end)
  if not ok or type(s) ~= "string" or #s == 0 then return nil end
  local n = utf8_seq_len(s, 1)
  if not n or n > #s then return s:sub(1, 1) end
  return s:sub(1, n)
end

-- Split a pair entry into (opener, closer): EXACTLY two well-formed
-- UTF-8 codepoints, no trailing bytes (PR #110 round 1 finding 3 +
-- round 2 finding 1 — "()x" and "(\xC2x" must be skipped entirely,
-- never partially honored). nil for malformed user additions:
-- skipped, not errors — the hook must never throw over a config typo.
local function split_pair(s)
  if type(s) ~= "string" or #s < 2 then return nil end
  local n1 = utf8_seq_len(s, 1)
  if not n1 or n1 >= #s then return nil end
  local n2 = utf8_seq_len(s, n1 + 1)
  if not n2 or n1 + n2 ~= #s then return nil end
  return s:sub(1, n1), s:sub(n1 + 1)
end

-- The pair set for `buf`: its language's entry if configured, else
-- `default`. Language resolves against the buffer the typed-edit
-- record names — NOT the currently active buffer, which a
-- context-switching command may have replaced by callback time
-- (PR #110 round 2, finding 2). `pmacs.lsp` is looked up lazily and
-- nil-guarded — this chunk loads before lsp.lua (Q#AP7). Non-table
-- values anywhere (a config typo like `pmacs.pair.sets.default =
-- "()"`) degrade to the default set, then to empty — never a throw
-- from the after-edit callback (round 2, finding 3).
local function set_for(buf)
  local lang
  if pmacs.lsp and pmacs.lsp.buffer_language then
    local ok, l = pcall(pmacs.lsp.buffer_language, buf)
    if ok then lang = l end
  end
  local sets = pmacs.pair.sets
  if type(sets) ~= "table" then return {} end
  local set = lang and sets[lang]
  if type(set) ~= "table" then set = sets.default end
  if type(set) ~= "table" then return {} end
  return set
end

-- opener → closer, and the set of closer codepoints.
local function maps_for(set)
  local openers, closers = {}, {}
  for _, entry in ipairs(set) do
    local o, c = split_pair(entry)
    if o then
      openers[o] = c
      closers[c] = true
    end
  end
  return openers, closers
end

-- Conservative insertion predicate (Q#AP3): pair only before
-- end-of-buffer, end-of-line, whitespace, or a closing char from the
-- active set — `foo|bar` + `(` gives `(bar`, never `()bar`.
local function should_pair(buf, cursor, closers)
  local nxt = char_at(buf, cursor)
  if nxt == nil then return true end
  if nxt == "\n" or nxt == "\r" or nxt == " " or nxt == "\t" then return true end
  return closers[nxt] == true
end

-- Right-gravity translation of `pos` through the effective edit —
-- indent.lua's repair shape (Q#AP3/Q#AP4 transformed outcomes).
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

-- Context-guarded cursor repair after a TRANSFORMED reaction edit:
-- the intercept's positional result stands (kind and payload are
-- immutable; the edit has already landed), so translate the pre-edit
-- cursor through the effective edit and clamp via goto_byte — unless
-- the intercept switched window or buffer, in which case the new
-- context is not ours to touch. The clean path deliberately performs
-- NO cursor motion: a clean at-cursor closer insert must leave the
-- cursor *before* the closer, which translation would not.
local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted)
  if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf0 then
    return
  end
  ed.goto_byte(translate(cursor0, estart, estop, einserted))
end

-- Test facility (leading underscore = not stable API), OFF by
-- default: the one-shot record must stay ephemeral in production —
-- retaining every consumed record in a public field would defeat the
-- Q#AP9 contract the take API enforces (PR #110 round 1, finding 4).
-- Acceptance tests flip `_capture_records` on; each fan-out then
-- publishes the record it observed (or nil) to `_last_record`, which
-- is how tests read the exact codepoint / effective triple and prove
-- one-shot-ness (this callback registers first and consumes it).
pmacs.pair._capture_records = false

pmacs.hook.add("buffer.after-edit", function()
  -- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
  -- manual hook run, rejected insert, a post-insert mutation by the
  -- command, stale `this_command` — is a silent non-event; only a
  -- live record for a pair-set character that then fails a gate
  -- reports.
  local rec = ed.take_typed_edit and ed.take_typed_edit()
  if pmacs.pair._capture_records then pmacs.pair._last_record = rec end
  if not rec then return end
  if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end

  local buf = pmacs.window.buffer()
  if not buf then return end

  -- Relevance first (PR #110 round 1, finding 2): pairing has no
  -- interest in characters outside the set, so a transformed or
  -- relocated ordinary `a` must stay silent — the reports below are
  -- for pair characters only. The set is the SOURCE buffer's (round
  -- 2, finding 2): `'` typed in Rust stays silent even when a
  -- context-switching command lands in Python, and `'` typed in
  -- Python still draws the context-change report when it lands in
  -- Rust.
  local ch = rec.char
  local openers, closers = maps_for(set_for(rec.buffer))
  if not (openers[ch] or closers[ch]) then return end

  -- Fail closed on a transformed source self-insert (Q#AP3): the
  -- intercept's positional result stands as produced; pairing on top
  -- of a relocated or expanded opener would compound it.
  if not rec.clean then
    ed.set_status("auto-pair skipped: source self-insert transformed")
    return
  end
  -- Fail closed when the source edit's context is no longer current:
  -- an intercept switched window/buffer, or something moved the
  -- cursor off the post-insert position. Best-effort by construction:
  -- the report needs this fan-out to run at all, and dispatch's
  -- active-buffer revision compare (the named buffer-aware edit-epoch
  -- deferral) skips the fan-out when a context-switching command
  -- lands on a buffer with a coincidentally equal revision — pairing
  -- still fails closed there, silently (the record dies un-armed).
  if buf ~= rec.buffer
    or pmacs.window.current() ~= rec.window
    or ed.cursor() ~= rec.post_cursor then
    ed.set_status("auto-pair skipped: source context changed")
    return
  end
  -- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has
  -- already consumed and cleared the region. A region surviving the
  -- edit means the TUI's selection-blind optimistic gate let a custom
  -- pair char through (named deferral) — reacting would pile a closer
  -- onto an unconsumed region.
  if ed.region() ~= nil then return end

  local cursor = rec.post_cursor

  -- Skip-over-close (Q#AP4), checked before insertion so symmetric
  -- pairs (quotes) step over their own closer: typing `)` at `(|)`
  -- swallows the freshly typed duplicate, net `()` with the cursor
  -- after — exactly Emacs's skip. The pair chars round-trip (Q#AP1),
  -- so no frontend ever painted the transient duplicate.
  if closers[ch] then
    local dup_ok, dup = pcall(function() return buf:slice(cursor, cursor + #ch) end)
    if dup_ok and dup == ch then
      local win0 = pmacs.window.current()
      local ok, estart, estop, einserted = pcall(function()
        return buf:delete(cursor, cursor + #ch)
      end)
      if not ok then
        -- The duplicate stays (e.g. `())`); report, no retry.
        ed.set_status("auto-pair skip rejected by buffer intercept")
        return
      end
      if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then
        ed.set_status("auto-pair skip altered by buffer intercept")
        repair_cursor(win0, buf, cursor, estart, estop, einserted)
      end
      return
    end
  end

  local closer = openers[ch]
  if not closer then return end
  if not should_pair(buf, cursor, closers) then return end

  local win0 = pmacs.window.current()
  local ok, estart, estop, einserted = pcall(function()
    return buf:insert(cursor, closer)
  end)
  if not ok then
    -- Nothing landed; the opener stands alone.
    ed.set_status("auto-pair closer rejected by buffer intercept")
    return
  end
  if estart ~= cursor or estop ~= cursor or einserted ~= #closer then
    ed.set_status("auto-pair closer altered by buffer intercept")
    repair_cursor(win0, buf, cursor, estart, estop, einserted)
  end
  -- Clean path: no cursor motion — the insert landed at the cursor
  -- and Lua mutators move no cursors, so it already sits between the
  -- pair; the daemon's per-tick CursorByte re-grounds both frontends.
end)
