//! Lean 4 Unicode input method acceptance (Arc 8 Stage 4b,
//! docs/lean4-mode-framing.md Q#LN11/Q#LN21/Q#LN22, criteria 38–45i).
//!
//! Dispatch-driven throughout: `dispatch_key` is the producer that arms
//! the typed-edit record for a grid frontend. The optimistic CRDT
//! producer is criterion 45f and lives in a `--lib` test, where the gate
//! list's `--features crdt` run reaches it.

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use pmacs::window::{FrontendView, Layout, Window, WindowId};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};

fn fresh_dir() -> PathBuf {
    static SEQ: AtomicUsize = AtomicUsize::new(0);
    let dir = std::env::temp_dir().join(format!(
        "pmacs-leaninput-{}-{}",
        std::process::id(),
        SEQ.fetch_add(1, Ordering::Relaxed)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn key(code: KeyCode) -> KeyEvent {
    KeyEvent {
        code,
        modifiers: KeyModifiers::NONE,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    }
}

fn exec(s: &EditorState, src: &str) {
    s.lua_host.lua().load(src.to_string()).exec().unwrap();
}

fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
    s.lua_host.lua().load(src.to_string()).eval().unwrap()
}

fn text(s: &EditorState) -> String {
    let b: mlua::String = eval(
        s,
        "local b = pmacs.window.buffer(); return b:slice(0, b:len())",
    );
    String::from_utf8_lossy(&b.as_bytes()).into_owned()
}

fn cursor(s: &EditorState) -> i64 {
    eval(s, "return pmacs.editor.cursor()")
}

fn type_as(s: &mut EditorState, fid: FrontendId, chars: &str) {
    for ch in chars.chars() {
        s.dispatch_key(fid, key(KeyCode::Char(ch)));
    }
}

fn type_str(s: &mut EditorState, chars: &str) {
    type_as(s, FrontendId::LOCAL, chars);
}

/// An editor with an empty `.lean` file open and the point at 0.
/// `pmacs.lsp.config = {}` keeps the real user config from spawning a
/// server; the language still resolves from the extension.
fn lean_editor() -> (EditorState, PathBuf) {
    let dir = fresh_dir();
    let f = dir.join("a.lean");
    std::fs::write(&f, "").unwrap();
    let s = EditorState::new();
    exec(&s, "pmacs.lsp.config = {}");
    let fd = f.display().to_string();
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    assert_eq!(
        eval::<Option<String>>(
            &s,
            "return pmacs.lsp.buffer_language(pmacs.window.buffer())"
        )
        .as_deref(),
        Some("lean4"),
        "the fixture must actually be a lean4 buffer, or every \
         expansion assertion below is vacuous"
    );
    (s, f)
}

// ---------------------------------------------------------------------------
// 38 / 41 — the two expansion paths, and what an undo restores
// ---------------------------------------------------------------------------

#[test]
fn the_finish_path_retains_the_terminator_in_one_undo_step() {
    // `alp` is not a key; `alpha` is the shortest key extending it. The
    // space does not extend anything, so it lands first and the
    // expansion replaces the leader and the typed text — the span stops
    // BEFORE the terminator, so whatever auto-pairing did with it
    // survives. One undo restores the same text either way, because the
    // terminator was its own insert to begin with.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alp ");
    assert_eq!(text(&s), "α ", "terminator retained, not consumed");

    exec(&s, "pmacs.window.buffer():undo()");
    assert_eq!(
        text(&s),
        "\\alp ",
        "one undo restores the pre-expansion text WITH its terminator — \
         the expansion is a single edit"
    );
}

#[test]
fn the_eager_path_takes_no_terminator_and_undoes_separately() {
    // `alpha` has no longer key extending it, so it is one of the 1,550
    // eager keys: it expands the moment the final `a` lands, and a
    // following space is a SEPARATE edit. Rev 8 asserted the finish-path
    // undo text for this example, which is the trap (round 9).
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alpha");
    assert_eq!(text(&s), "α", "eager expansion, no terminator typed");

    type_str(&mut s, " ");
    assert_eq!(text(&s), "α ");
    exec(&s, "pmacs.window.buffer():undo()");
    assert_eq!(text(&s), "α", "the first undo removes the separate space");
    exec(&s, "pmacs.window.buffer():undo()");
    assert_eq!(text(&s), "\\alpha", "the second undoes the expansion");
}

#[test]
fn to_is_not_eager_because_longer_keys_extend_it() {
    // The criterion rev 8 got wrong: `to` looks unique and is not.
    // `top`, `to0`, `toa` and others extend it, so it needs a
    // terminator. Bites against an eager rule that tests only "is this
    // a key" without asking whether anything extends it.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\to");
    assert_eq!(text(&s), "\\to", "no expansion without a terminator");

    type_str(&mut s, " ");
    assert_eq!(text(&s), "→ ", "the finish path then resolves it");
}

// ---------------------------------------------------------------------------
// 39 — $CURSOR
// ---------------------------------------------------------------------------

#[test]
fn the_cursor_placeholder_places_the_point_between_the_symbols() {
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\<>");
    assert_eq!(text(&s), "⟨⟩");
    // The placeholder is a point position, not a literal: typing lands
    // between the brackets.
    type_str(&mut s, "x");
    assert_eq!(text(&s), "⟨x⟩", "$CURSOR left the point inside");
}

// ---------------------------------------------------------------------------
// 40 — the pair collision
// ---------------------------------------------------------------------------

#[test]
fn a_pending_abbreviation_is_never_corrupted_by_auto_pairing() {
    // 64 keys contain a `lean4` pair-set character. Two DISTINCT bugs
    // produce the same symptom here, so both are asserted: pairing
    // running first, and a consumer that claims only completed
    // expansions (which would hand each intermediate `[` to pairing).
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\[");
    assert_eq!(
        text(&s),
        "\\[",
        "the intermediate `[` was CLAIMED — pairing inserted no `]`, \
         which is what keeps `\\[[]]` reachable"
    );

    type_str(&mut s, "[]]");
    assert_eq!(text(&s), "⟦⟧", "the full key resolves");
}

#[test]
fn a_pair_character_that_terminates_an_abbreviation_still_pairs() {
    // The other half of the collision, and the one the first revision
    // of this file got wrong. `(` does not extend `alp`, so it
    // TERMINATES — and a terminator is an ordinary character that
    // pairing is entitled to react to.
    //
    // Claiming the terminator suppresses pairing entirely (`α(`).
    // Expanding before declining is no better: the replace makes
    // pairing's copy of the record stale, so pairing declines and the
    // closer is silently lost. Only deferring the expansion past the
    // chain gives both.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alp(");
    assert_eq!(
        text(&s),
        "α()",
        "the abbreviation expanded AND the terminator paired"
    );
    assert_eq!(
        cursor(&s),
        3,
        "and the point sits between the pair — after α (2 bytes) and \
         the opener"
    );
}

#[test]
fn a_nested_fan_out_between_the_expander_and_pairing_does_not_expand_early() {
    // `buffer.after-edit` fan-outs NEST — the typed-edit contract
    // explicitly supports a consumer calling `pmacs.hook.run`, and a
    // nested run re-enters every subscriber, including the deferred
    // expansion's. If the nested pass performed the expansion, the
    // OUTER chain would then resume and hand pairing a record the
    // replace had already invalidated: `α(` again, reached through the
    // chain's documented re-entrancy seam rather than through claiming.
    let (mut s, _f) = lean_editor();
    exec(
        &s,
        r#"
        _G.NESTED = 0
        pmacs.typed_edit.add_consumer {
          name = "nested-fan-out",
          priority = 75,   -- between the expander (50) and pairing (100)
          fn = function()
            if _G.NESTED == 0 then
              _G.NESTED = 1
              pmacs.hook.run("buffer.after-edit")
            end
            return false
          end,
        }
        "#,
    );

    type_str(&mut s, "\\alp(");
    let nested: i64 = eval(&s, "return _G.NESTED");
    assert_eq!(nested, 1, "the nested fan-out must actually have run");
    assert_eq!(
        text(&s),
        "α()",
        "the expansion waited for the OUTERMOST pass, so pairing still \
         held a valid record when the terminator reached it"
    );
}

#[test]
fn a_nested_fan_out_that_never_reaches_the_expander_still_does_not_expand_early() {
    // The chain's OTHER exit: a consumer may CLAIM and stop the chain
    // before the expander is reached, while the fan-out's
    // deferred-expansion subscriber still runs. Counting in the
    // expander itself therefore misses that pass — it would look like
    // the outermost one and expand early, and outer pairing would
    // resume with an invalidated record.
    //
    // The sequence, exactly: a consumer at 25 declines on the outer
    // pass (there is a record) and claims on the nested one (there is
    // not); a consumer at 75 runs one nested fan-out from between the
    // expander and pairing.
    let (mut s, _f) = lean_editor();
    exec(
        &s,
        r#"
        _G.NESTED, _G.CLAIMED = 0, 0
        pmacs.typed_edit.add_consumer {
          name = "claims-only-when-recordless",
          priority = 25,   -- ahead of the expander at 50
          fn = function(rec)
            if rec == nil then
              _G.CLAIMED = _G.CLAIMED + 1
              return true   -- stops the chain: the expander never runs
            end
            return false
          end,
        }
        pmacs.typed_edit.add_consumer {
          name = "nested-fan-out",
          priority = 75,   -- between the expander (50) and pairing (100)
          fn = function()
            if _G.NESTED == 0 then
              _G.NESTED = 1
              pmacs.hook.run("buffer.after-edit")
            end
            return false
          end,
        }
        "#,
    );

    type_str(&mut s, "\\alp(");
    let (nested, claimed): (i64, i64) = eval(&s, "return _G.NESTED, _G.CLAIMED");
    assert_eq!(nested, 1, "the nested fan-out must actually have run");
    assert!(
        claimed >= 1,
        "the nested pass must actually have been short-circuited before \
         the expander, or this pins the same thing as 45n"
    );
    assert_eq!(
        text(&s),
        "α()",
        "the nesting count comes from a point that runs before any \
         consumer can claim, so the nested pass was still recognised"
    );
}

#[test]
fn a_pair_character_outside_a_pending_abbreviation_still_pairs() {
    // The other direction: claiming extensions must not disable pairing
    // in Lean buffers generally.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "[");
    assert_eq!(text(&s), "[]", "ordinary auto-pairing is untouched");
}

// ---------------------------------------------------------------------------
// 42 — a prefix that opens nothing
// ---------------------------------------------------------------------------

#[test]
fn a_prefix_that_opens_no_key_is_left_literal_with_no_edit() {
    // `W` is one of exactly six printable characters that begin no key
    // (`$ % , ; @ W`). Rev 8 used `\zzzz`, which expands — `ze`, `zeta`
    // and `zsqrtd` exist (round 9).
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\WWWW ");
    assert_eq!(text(&s), "\\WWWW ", "literal text, no expansion");
}

#[test]
fn a_prefix_with_no_complete_match_still_expands_its_best_prefix() {
    // The case rev 8 mistook for "no match": `z` DOES open a pending
    // abbreviation, and the second `z` finishes it.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\zzzz ");
    assert_eq!(text(&s), "ζzzz ", "`z` resolved through `ze`");
}

// ---------------------------------------------------------------------------
// 43 — lazy abandonment
// ---------------------------------------------------------------------------

#[test]
fn moving_the_point_away_abandons_the_pending_abbreviation() {
    // There is no cursor-motion hook, so the pending record is
    // validated at the NEXT typed edit: the point must still be at the
    // end of the pending span.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alp");
    exec(&s, "pmacs.editor.goto_byte(0)");
    type_str(&mut s, "h");
    assert_eq!(text(&s), "h\\alp", "the `h` landed as plain text");

    // The keystroke that makes abandonment OBSERVABLE. Asserting only
    // the line above proves nothing: claiming an extension makes no
    // edit, so a record that wrongly survived would look identical
    // here. If `h` had extended the record to `alph`, this `a`
    // completes `alpha` and eagerly expands — over a span whose offsets
    // are now stale by one.
    type_str(&mut s, "a");
    assert_eq!(
        text(&s),
        "ha\\alp",
        "`\\alp` is still literal: the record was dropped when the \
         point left the end of its span, not carried along"
    );
}

#[test]
fn switching_buffers_clears_pending_state_eagerly() {
    let (mut s, f) = lean_editor();
    let dir = fresh_dir();
    let other = dir.join("b.lean");
    std::fs::write(&other, "").unwrap();
    let od = other.display().to_string();
    let fd = f.display().to_string();

    // Open the second buffer FIRST, then come back. `find_or_open`
    // fires `buffer.after-switch` only on the already-open branch — a
    // fresh load fires `buffer.after-load` instead, and its own insert
    // fires a record-less `buffer.after-edit`. Without this warm-up the
    // test passes through the nil-record path and pins nothing about
    // switching: deleting the after-switch subscriber leaves it green.
    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");

    type_str(&mut s, "\\alph");
    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");

    type_str(&mut s, "a");
    assert_eq!(
        text(&s),
        "\\alpha",
        "without the switch this would have eagerly expanded to α; \
         `buffer.after-switch` cleared the record"
    );
}

#[test]
fn a_self_insert_that_moves_the_point_afterwards_does_not_expand() {
    // Buffer and window matching is not enough. A redefined
    // `buffer.self-insert` may insert the completing character and THEN
    // move the point; expanding over a span the user has left teleports
    // them back into it. Pairing makes the same three-part check
    // (`ed.cursor() ~= rec.post_cursor`) for the same reason.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alph");
    exec(
        &s,
        r#"
        pmacs.command.unregister("buffer.self-insert")
        pmacs.command.define {
          name = "buffer.self-insert",
          description = "test override: insert, then move the point away",
          fn = function(cp)
            pmacs.editor.insert_char_over_region(cp)
            pmacs.editor.goto_byte(0)
          end,
        }
        "#,
    );

    type_str(&mut s, "a");
    assert_eq!(
        text(&s),
        "\\alpha",
        "the record died with the point that left it — no expansion"
    );
    assert_eq!(cursor(&s), 0, "and the point stayed where it was moved to");
}

#[test]
fn an_intercept_that_switches_buffers_does_not_move_the_other_points() {
    // A buffer intercept may switch window or buffer while the replace
    // runs. An unguarded `goto_byte` afterwards moves the point of
    // whatever it switched TO — a buffer with nothing to do with this
    // expansion. Pairing's `repair_cursor` guards the same way.
    let (mut s, f) = lean_editor();
    let dir = fresh_dir();
    let other = dir.join("other.lean");
    std::fs::write(&other, "0123456789").unwrap();
    let od = other.display().to_string();
    let fd = f.display().to_string();

    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    exec(
        &s,
        &format!(
            r#"
            _G.SWITCHED = false
            pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
              if op.kind == "replace" and not _G.SWITCHED then
                _G.SWITCHED = true
                pmacs.buffer.find_or_open({od:?})
              end
              return nil
            end)
            "#
        ),
    );

    type_str(&mut s, "\\alpha");
    let switched: bool = eval(&s, "return _G.SWITCHED");
    assert!(switched, "the intercept must actually have fired");
    assert_eq!(
        text(&s),
        "0123456789",
        "we are now in the buffer the intercept switched to"
    );
    // Whatever point the switch left in that buffer, the expansion must
    // not have moved it. Unguarded, `goto_byte` runs against the
    // ambient buffer and translates the LEAN buffer's pre-edit point
    // (6) through the LEAN buffer's replace, landing at 2 here — a
    // number with no meaning in this buffer at all.
    assert_eq!(
        cursor(&s),
        0,
        "its point is untouched — the expansion's cursor placement is \
         guarded on the window and buffer still being the ones it \
         edited"
    );
}

// ---------------------------------------------------------------------------
// 44 / 45 — the setting and the language gate, both on the SOURCE buffer
// ---------------------------------------------------------------------------

#[test]
fn disabling_the_setting_stops_expansion() {
    let (mut s, _f) = lean_editor();
    exec(&s, "pmacs.config.set('lean.abbrev', false)");
    type_str(&mut s, "\\alpha");
    assert_eq!(text(&s), "\\alpha", "no expansion when disabled");

    exec(&s, "pmacs.config.set('lean.abbrev', true)");
    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
    type_str(&mut s, " \\alpha");
    assert_eq!(text(&s), "\\alpha α", "and it comes back live");
}

#[test]
fn the_setting_is_read_against_the_typed_edits_source_buffer() {
    // A buffer-local override must not follow the user to another
    // buffer of the same language — the `editing.auto-pair` precedent,
    // including its round-2 correction to resolve `rec.buffer` rather
    // than `pmacs.window.buffer()`.
    let (mut s, f) = lean_editor();
    let dir = fresh_dir();
    let other = dir.join("b.lean");
    std::fs::write(&other, "").unwrap();

    exec(
        &s,
        "pmacs.config.set_local(pmacs.window.buffer(), 'lean.abbrev', false)",
    );
    type_str(&mut s, "\\alpha");
    assert_eq!(text(&s), "\\alpha", "disabled in THIS buffer");

    let od = other.display().to_string();
    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    type_str(&mut s, "\\alpha");
    assert_eq!(text(&s), "α", "a second lean buffer is unaffected");

    let fd = f.display().to_string();
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    assert_eq!(text(&s), "\\alpha", "and the first is still disabled");
}

#[test]
fn no_abbreviation_state_is_opened_outside_a_lean_buffer() {
    let dir = fresh_dir();
    let f = dir.join("a.rs");
    std::fs::write(&f, "").unwrap();
    let s = EditorState::new();
    exec(&s, "pmacs.lsp.config = {}");
    let fd = f.display().to_string();
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    let mut s = s;

    type_str(&mut s, "\\alpha");
    assert_eq!(text(&s), "\\alpha", "no expansion in Rust");

    // And the leader opened nothing, so `[` still pairs normally.
    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
    type_str(&mut s, "\\[");
    assert_eq!(
        text(&s),
        "\\alpha\\[]",
        "`\\[` in a Rust buffer pairs — the input method never armed"
    );
}

// ---------------------------------------------------------------------------
// 45a / 45b / 45c / 45d / 45e — resolution rules
// ---------------------------------------------------------------------------

#[test]
fn the_shortest_key_wins_not_the_longest() {
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alp ");
    assert_eq!(text(&s), "α ", "`alp` resolves through `alpha`");

    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
    type_str(&mut s, "\\al ");
    assert_eq!(
        text(&s),
        "α ∀ ",
        "`al` resolves through `all` (3) — NOT `alpha` (5). A \
         longest-match or unique-match-only rule passes the first \
         assertion and fails this one"
    );
}

#[test]
fn an_unmatchable_tail_is_appended_not_dropped() {
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\alp7 ");
    assert_eq!(
        text(&s),
        "α7 ",
        "`7` finished `alp`; it is kept, not swallowed, and the whole \
         abbreviation is not abandoned"
    );
}

#[test]
fn there_is_no_terminator_list() {
    // `'+ '` is a key — a trailing SPACE is part of it. Bites against
    // any hardcoded space/tab/RET terminator set.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\+ ");
    assert_eq!(text(&s), "⊹", "the space EXTENDED rather than terminating");
}

#[test]
fn a_doubled_backslash_yields_one_literal_backslash() {
    // Not a terminator case: the pending text is empty, `\` is itself a
    // key, and it extends-and-eagerly-matches.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\\\");
    assert_eq!(text(&s), "\\", "one literal backslash");

    // ...and no pending state was left open, so an ordinary letter is
    // an ordinary letter.
    type_str(&mut s, "n");
    assert_eq!(text(&s), "\\n", "two characters, not a newline");
}

#[test]
fn a_terminating_backslash_re_arms_as_a_new_leader() {
    // `al` is NOT eager, so its pending record is still open when the
    // second `\` arrives: the `\` terminates it, the expansion runs,
    // and the same `\` must then open a fresh abbreviation.
    //
    // The framing's own example — `\alpha\to` — does NOT exercise this
    // branch: `alpha` is eager, so the record is already closed and the
    // `\` is handled by the ordinary open-a-leader path. It passes with
    // the re-arm branch deleted, which is why the non-eager case is the
    // one asserted first.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\al\\to ");
    assert_eq!(
        text(&s),
        "∀→ ",
        "the terminating `\\` expanded `al` AND opened a new \
         abbreviation at its own position"
    );

    // The criterion's example still holds, by the other route.
    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
    type_str(&mut s, "\\alpha\\to ");
    assert_eq!(text(&s), "∀→ α→ ");
}

#[test]
fn an_inserted_backslash_does_not_re_arm() {
    // `setminus` expands to a literal `\`. That backslash is a
    // programmatic replace, which arms no typed-edit record — so it
    // opens no pending abbreviation. Bites against a future consumer
    // that infers pending state from buffer text instead of provenance.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\setminus");
    assert_eq!(text(&s), "\\", "expanded to a literal backslash");

    type_str(&mut s, "n");
    assert_eq!(
        text(&s),
        "\\n",
        "the letter after it is a plain letter — the INSERTED backslash \
         armed nothing"
    );
}

// ---------------------------------------------------------------------------
// 45h — the tie-break by source declaration order
// ---------------------------------------------------------------------------

#[test]
fn equal_length_candidates_break_by_source_declaration_order() {
    // `f<` and `f>` are both length 2. `f<` is declared first, so `\f`
    // resolves to `‹`. This is the criterion that bites a map-shaped
    // vendored table: with `pairs` iteration it passes or fails by hash
    // order.
    let (mut s, _f) = lean_editor();
    type_str(&mut s, "\\f ");
    assert_eq!(text(&s), "‹ ", "`f<` wins over `f>` by source order");

    exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
    type_str(&mut s, "\\\" ");
    assert_eq!(
        text(&s),
        "‹ Ä ",
        "`\"A` is the first of eleven equal-length candidates"
    );
}

#[test]
fn reversing_the_vendored_sequence_reverses_the_tie() {
    // The falsification 45h requires: run the same resolution against a
    // deliberately reversed sequence and show it changes. If this did
    // NOT change, the tie-break would not be reading source order at
    // all and the assertion above would be passing by luck.
    let (s, _f) = lean_editor();
    let forward: String = eval(&s, "return pmacs.lean_input._resolve('f')");
    assert_eq!(forward, "‹");

    let reversed: String = eval(
        &s,
        "
        local seq = pmacs.lean_abbrev
        local rev = {}
        for i = #seq, 1, -1 do rev[#rev + 1] = seq[i] end
        -- Resolve `f` the way the module does, over the reversed order.
        local best = nil
        for i = 1, #rev do
          local k, v = rev[i][1], rev[i][2]
          if k:sub(1, 1) == 'f' then
            if best == nil or #k < best.len then best = { sym = v, len = #k } end
          end
        end
        return best.sym
        ",
    );
    assert_eq!(
        reversed, "›",
        "reversed source order picks `f>` — the tie really is decided \
         by position in the sequence"
    );
}

// ---------------------------------------------------------------------------
// 45g — table integrity, limited to what the suite can actually check
// ---------------------------------------------------------------------------

#[test]
fn the_vendored_table_is_self_consistent() {
    // `abbreviations.json` is not shipped, so the suite cannot diff
    // against it; the full source-fidelity check belongs to the
    // generator, which re-parses its own output from disk. What is
    // checkable here are the properties a corrupt emit breaks.
    let (s, _f) = lean_editor();

    let count: i64 = eval(&s, "return #pmacs.lean_abbrev");
    assert_eq!(
        count, 1855,
        "the declared entry count for the recorded upstream commit"
    );

    let (unique, cursor_ok, utf8_ok): (i64, bool, bool) = eval(
        &s,
        r#"
        local seen, n = {}, 0
        local cursor_ok, utf8_ok = true, true
        for i = 1, #pmacs.lean_abbrev do
          local k, v = pmacs.lean_abbrev[i][1], pmacs.lean_abbrev[i][2]
          if not seen[k] then seen[k] = true; n = n + 1 end
          local _, c = v:gsub("%$CURSOR", "")
          if c > 1 then cursor_ok = false end
          -- A Lua pattern cannot validate UTF-8; check the shape the
          -- emitter guarantees instead: no lone continuation byte at the
          -- start of a sequence and no truncated tail.
          if k:find("[\128-\191]") == 1 then utf8_ok = false end
        end
        return n, cursor_ok, utf8_ok
        "#,
    );
    assert_eq!(
        unique, 1855,
        "every key is unique — a collision would silently drop entries \
         from the derived lookup"
    );
    assert!(cursor_ok, "no symbol carries more than one $CURSOR");
    assert!(utf8_ok, "no key begins with a continuation byte");

    // The resolution spot-set named by 45g.
    for (input, want) in [
        ("alpha", "α"),
        ("to", "→"),
        ("<>", "⟨$CURSOR⟩"),
        ("+ ", "⊹"),
        ("\\\\", "\\"),
        ("n", "\\n"),
        ("setminus", "\\"),
        ("f", "‹"),
    ] {
        let got: String = eval(&s, &format!("return pmacs.lean_input._resolve('{input}')"));
        assert_eq!(got, want, "resolution of {input:?}");
    }

    // The eager set is the one the state machine branches on.
    let alpha_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('alpha')");
    let to_eager: bool = eval(&s, "return pmacs.lean_input._is_eager('to')");
    assert!(alpha_eager, "`alpha` has no extension");
    assert!(!to_eager, "`to` is extended by `top`, `to0`, `toa`, …");
}

// ---------------------------------------------------------------------------
// Q#AP7 for the deferred expansion: it must land before lsp.lua flushes
// ---------------------------------------------------------------------------

#[test]
fn the_expansion_reaches_the_first_did_change() {
    // The expansion runs on its OWN `buffer.after-edit` subscriber,
    // after the typed-edit chain. That makes it a new instance of the
    // Q#AP7 obligation pairing already carries: lsp.lua's subscriber
    // flushes `didChange` SYNCHRONOUSLY on the signature-trigger path,
    // and `(` is a trigger. A server told about `\alp(` instead of
    // `α()` stays wrong until the next edit — diagnostics, semantic
    // tokens and inlay hints all frozen at stale byte positions.
    //
    // Falsified by loading lean_input.lua after lsp.lua in
    // `src/editor.rs`: the expansion would then arrive in the SECOND
    // didChange, or not at all.
    let dir = fresh_dir();
    let sink = dir.join("changes.jsonl");
    let sink_disp = sink.display().to_string();
    let fake = env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned();

    let f = dir.join("a.lean");
    std::fs::write(&f, "").unwrap();
    let mut s = EditorState::new();
    s.lua_host.lua().remove_app_data::<StateDir>();
    s.lua_host.lua().set_app_data(StateDir(dir.clone()));
    exec(&s, "pmacs.lsp.config = {}");
    exec(
        &s,
        &format!(
            "pmacs.lsp.config.lean4 = {{
               command = '{fake}',
               env = {{
                 PMACS_FAKE_LSP_MODE = 'sighelp',
                 PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}',
               }},
             }}"
        ),
    );

    let fd = f.display().to_string();
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    let initialized = "(function() \
       for _,r in ipairs(pmacs.lsp.list()) do \
         if r.state and r.state.kind=='initialized' then return true end \
       end \
       return false \
     end)()";
    assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init");

    type_str(&mut s, "\\alp(");
    assert_eq!(text(&s), "α()", "precondition: the expansion happened");

    // Wait for the flush that carries the `(` keystroke. Earlier
    // keystrokes have already produced their own didChanges, so
    // `changes[0]` is NOT the one under test — asserting on it compares
    // against `\al` and fails for the wrong reason.
    let deadline = Instant::now() + Duration::from_secs(5);
    let changes = loop {
        s.tick_processes();
        s.tick_lsp();
        s.tick_async();
        let c = did_change_texts(&sink);
        if c.iter().any(|t| t.contains('α')) {
            break c;
        }
        assert!(
            Instant::now() < deadline,
            "no didChange carrying the expansion reached the fake server;              got {:?}",
            did_change_texts(&sink)
        );
        std::thread::sleep(Duration::from_millis(10));
    };
    assert!(
        !changes.iter().any(|t| t == "\\alp("),
        "no didChange may ever carry the UNEXPANDED text — one would          mean lsp.lua flushed before the deferred expansion ran (Q#AP7).          Got {changes:?}"
    );
    assert_eq!(
        changes.last().map(String::as_str),
        Some("α()"),
        "the flush that carries the terminator carries the expansion          and pairing's closer with it"
    );
}

fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool {
    let deadline = Instant::now() + Duration::from_secs(secs);
    loop {
        state.tick_processes();
        state.tick_lsp();
        state.tick_async();
        let done: bool = state
            .lua_host
            .lua()
            .load(format!("return ({flag}) == true"))
            .eval()
            .unwrap_or(false);
        if done {
            return true;
        }
        if Instant::now() >= deadline {
            return false;
        }
        std::thread::sleep(Duration::from_millis(10));
    }
}

/// The `text` of every `textDocument/didChange` line in the sink, in
/// arrival order.
fn did_change_texts(sink: &std::path::Path) -> Vec<String> {
    let Ok(raw) = std::fs::read_to_string(sink) else {
        return Vec::new();
    };
    raw.lines()
        .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
        .filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange"))
        .filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned))
        .collect()
}

// ---------------------------------------------------------------------------
// 45i — pending state is per frontend
// ---------------------------------------------------------------------------

/// Register a second frontend on the SAME buffer, with its own window.
fn attach_frontend(s: &EditorState, fid: FrontendId) -> WindowId {
    let mut core = s.core.borrow_mut();
    let buffer_id = core.active_buffer_id();
    let text_view = {
        let registry = core.registry.clone();
        let reg = registry.borrow();
        pmacs::text_view::TextView::new(reg.get(buffer_id).unwrap())
    };
    let win_id = WindowId::next();
    core.windows
        .insert(win_id, Window::new(win_id, buffer_id, text_view));
    core.register_frontend_view(
        fid,
        FrontendView {
            layout: Layout::single(win_id),
            active: win_id,
            fold_projection: true,
            panel_capable: true,
            frame_geometry: None,
            panel_hidden: false,
        },
    );
    win_id
}

const B: FrontendId = FrontendId(9);

#[test]
fn a_peer_edit_to_the_shared_buffer_abandons_the_pending_record() {
    let (mut s, _f) = lean_editor();
    let b_win = attach_frontend(&s, B);
    // B sits at the start of the buffer; A types at the end.
    s.core.borrow_mut().windows.get_mut(&b_win).unwrap().cursor = 0;

    type_as(&mut s, FrontendId::LOCAL, "\\al");
    type_as(&mut s, B, "p");
    assert!(
        text(&s).contains('p'),
        "B's keystroke landed as ordinary text rather than extending \
         A's abbreviation, got {:?}",
        text(&s)
    );

    type_as(&mut s, FrontendId::LOCAL, "l ");
    assert!(
        !text(&s).contains('∀'),
        "A's record was abandoned: `revision()` is buffer-global, so \
         B's edit invalidates it even though B edited elsewhere. Got {:?}",
        text(&s)
    );
}

#[test]
fn a_peer_buffer_switch_does_not_clear_another_frontends_record() {
    let (mut s, f) = lean_editor();
    let dir = fresh_dir();
    let other = dir.join("b.lean");
    std::fs::write(&other, "").unwrap();
    let od = other.display().to_string();
    let fd = f.display().to_string();
    // Warm up both buffers so B's switch takes `find_or_open`'s
    // already-open branch, which is the only one that fires
    // `buffer.after-switch`. A fresh load fires `buffer.after-load`
    // and a record-less edit instead — and that path clears pending
    // state for a different reason, which would make this test green
    // no matter whose entries the subscriber clears.
    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    attach_frontend(&s, B);

    type_as(&mut s, FrontendId::LOCAL, "\\al");

    // B switches buffers WITHOUT editing the shared buffer.
    // Only B moves: the switch is scoped to B's own window, so A's
    // window still shows the shared buffer with A's point where it was.
    s.core.borrow_mut().active_frontend = B;
    exec(&s, &format!("pmacs.buffer.find_or_open({od:?})"));
    s.core.borrow_mut().active_frontend = FrontendId::LOCAL;

    type_as(&mut s, FrontendId::LOCAL, "l ");
    assert_eq!(
        text(&s),
        "∀ ",
        "`buffer.after-switch` clears only the ACTING frontend's \
         entries — a blanket clear would discard A's half-typed \
         abbreviation"
    );
}

#[test]
fn detaching_a_frontend_purges_only_its_own_pending_state() {
    let (mut s, _f) = lean_editor();
    attach_frontend(&s, B);

    type_as(&mut s, FrontendId::LOCAL, "\\al");
    exec(&s, &format!("pmacs.hook.run('frontend.detached', {})", B.0));

    type_as(&mut s, FrontendId::LOCAL, "l ");
    assert_eq!(
        text(&s),
        "∀ ",
        "B's detachment purged B's entries and left A's record valid"
    );
}
