// tests/m8_9_acceptance.rs --- T M8.9 outline-class structure parser & view.

//! Acceptance for the M8.9 deliverable: a foldable outline view
//! built on a two-buffer projection (source + visible), with an
//! org-shaped headline parser whose lazy-incremental cache is
//! invalidated by an intercept observer. Folding visually replaces
//! a folded subtree's body bytes with a `...` marker; selective
//! rendering derives from the parsed structure.
//!
//! The package source-of-record is `tests/fixtures/pmacs-outline/`.
//!
//! The four spec acceptance bullets:
//!
//! 1. Outline buffer with five levels of nested headlines and 100
//!    entries renders within 100 ms (parse + projection).
//! 2. Edits update the parsed structure incrementally; the cache
//!    invalidation hook does not reparse the whole buffer for
//!    every keystroke.
//! 3. Properties on headlines (`:tag:`, key-value pairs) parse and
//!    are queryable.
//! 4. Navigation commands (next-headline, parent-headline,
//!    fold-subtree) work.
//!
//! Plus regressions:
//!
//! * Selective rendering: fold collapses subtree bytes to a marker.
//! * Source edits propagate to the visible buffer (lazy repaint).
//! * Property drawer survives an in-body edit (Pass-2 finding 2).
//! * Visible buffer is read-only (intercept rejects user edits).
//! * Initial parse populates entries with correct byte ranges.
//! * Edit followed by no query is free (zero parse-call increments).

use std::path::PathBuf;
use std::time::{Duration, Instant};

use pmacs::editor::EditorState;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;

fn outline_package_path() -> PathBuf {
    let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    here.join("tests").join("fixtures").join("pmacs-outline")
}

fn editor_with_outline() -> (EditorState, TempDir, TempDir) {
    let cache = tempfile::tempdir().expect("cache tempdir");
    let user_root = tempfile::tempdir().expect("user-root tempdir");
    let mut state = EditorState::new();
    state.lua_host.reopen_init_phase_for_testing();
    state.lua_host.set_package_install_override(
        PackageInstallOverride::new()
            .with_cache_dir(cache.path().to_path_buf())
            .with_user_install_root(user_root.path().to_path_buf()),
    );
    let pkg = outline_package_path();
    let pkg_str = pkg.display().to_string();
    let install = format!(
        r#"
        pmacs.packages.install_local("{pkg_str}")
        require("pmacs-outline")
    "#
    );
    state
        .lua_host
        .eval(Some("outline-install"), &install)
        .unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
    (state, cache, user_root)
}

#[test]
fn public_pmacs_outline_query_returns_matching_entries() {
    let (mut state, _cache, _user_root) = editor_with_outline();
    state
        .lua_host
        .eval(
            Some("public-outline-query"),
            r#"
                _G.SRC = pmacs.buffer.create("*outline-query-src*")
                _G.SRC:replace(0, 0,
                    "* TODO alpha :todo:\nbody\n" ..
                    "* DONE beta :done:\nbody\n" ..
                    "* TODO gamma :todo:\nbody\n")
                local hits = pmacs.outline.query(_G.SRC, function(e)
                    return e.tagset and e.tagset.todo
                end)
                _G.HIT_COUNT = #hits
                _G.FIRST_TITLE = hits[1].title
                _G.SECOND_TITLE = hits[2].title
            "#,
        )
        .expect("public outline query");

    let count: i64 = state.lua_host.lua().globals().get("HIT_COUNT").unwrap();
    assert_eq!(count, 2);
    let first: String = state.lua_host.lua().globals().get("FIRST_TITLE").unwrap();
    let second: String = state.lua_host.lua().globals().get("SECOND_TITLE").unwrap();
    assert_eq!(first, "TODO alpha");
    assert_eq!(second, "TODO gamma");
}

/// Boot an outline view over a fresh source buffer pre-populated
/// with `text`. After this call the visible projection buffer is
/// active in the window. Globals stashed for test access:
///
///   _G.H      --- outline handle
///   _G.SRC    --- source buffer (editable, holds outline text)
///   _G.VIS    --- visible buffer (read-only projection)
///   _G.PARSER --- parser module (test-seam helpers)
///   _G.PH     --- parser handle (passed to PARSER.* functions)
///
/// Uses Lua long-bracket strings so embedded newlines / quotes /
/// backslashes need no escaping; texts containing `]==]` would
/// break this, but no acceptance corpus does.
fn open_outline_with(state: &mut EditorState, text: &str) {
    assert!(!text.contains("]==]"), "test text must not contain ]==]");
    let script = format!(
        r#"
            local outline = require("pmacs-outline")
            local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
            te.parser.__pmacs_outline_test_reset_parse_count()
            local src = pmacs.buffer.create("*outline-src*")
            local payload = [==[{text}]==]
            src:replace(0, src:len(), payload)
            local h = outline.open(src)
            _G.H = h
            _G.SRC = src
            _G.VIS = h.visible
            _G.PARSER = te.parser
            _G.PH = h.parser_handle
        "#
    );
    state
        .lua_host
        .eval(Some("outline-open"), &script)
        .unwrap_or_else(|e| panic!("open outline view failed: {e}"));
}

fn parse_count(state: &mut EditorState) -> i64 {
    state
        .lua_host
        .lua()
        .load(r"return PARSER.__pmacs_outline_test_parse_count()")
        .eval::<i64>()
        .expect("read parse count")
}

fn cursor_line(state: &mut EditorState) -> i64 {
    state
        .lua_host
        .lua()
        .load(r"return pmacs.editor.cursor_line()")
        .eval()
        .expect("cursor line")
}

fn visible_text(state: &mut EditorState) -> String {
    state
        .lua_host
        .lua()
        .load(r"return VIS:slice(0, VIS:len())")
        .eval()
        .expect("visible text")
}

// ---------------------------------------------------------------------------
// Bullet 1 --- render within 100ms (parse + projection)
// ---------------------------------------------------------------------------

fn build_5_level_100_entry_text() -> String {
    use std::fmt::Write as _;
    let mut s = String::new();
    let mut count = 0;
    let mut level: usize = 1;
    while count < 100 {
        let stars = "*".repeat(level);
        writeln!(s, "{stars} entry-{count}").expect("write");
        writeln!(s, "body-{count}-line-1\nbody-{count}-line-2").expect("write");
        count += 1;
        level = if level == 5 { 1 } else { level + 1 };
    }
    s
}

#[test]
fn outline_5_level_100_entry_renders_within_100ms() {
    let (mut state, _c, _u) = editor_with_outline();
    let text = build_5_level_100_entry_text();
    let start = Instant::now();
    open_outline_with(&mut state, &text);
    let elapsed = start.elapsed();

    // Sanity: parse picked up 100 entries.
    let count: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("entries count");
    assert_eq!(count, 100, "must parse 100 headlines; got {count}");

    // Visible buffer has been painted (initial render is synchronous in
    // open()). Length must be >0; without folds, projection equals source.
    let vis_len: i64 = state
        .lua_host
        .lua()
        .load(r"return VIS:len()")
        .eval()
        .expect("vis len");
    assert!(vis_len > 0, "visible buffer must be populated by open()");

    assert!(
        elapsed < Duration::from_millis(100),
        "open() (parse + render) took {elapsed:?}; spec budget is 100ms"
    );
}

// ---------------------------------------------------------------------------
// Bullet 2 --- incremental parse cache (lazy-reparse-on-query)
// ---------------------------------------------------------------------------

const SAMPLE_TEXT: &str =
    "* A\nbody-A-1\nbody-A-2\n** A1\nbody-A1\n* B\nbody-B\n** B1\nbody-B1\n* C\nbody-C\n";

#[test]
fn outline_initial_parse_populates_entries_with_byte_ranges() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);

    let count: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("entries");
    assert_eq!(count, 5, "expected 5 headlines; got {count}");

    let titles: Vec<String> = (1..=5)
        .map(|i| {
            state
                .lua_host
                .lua()
                .load(format!("return PARSER.entries(PH)[{i}].title"))
                .eval::<String>()
                .expect("title")
        })
        .collect();
    assert_eq!(titles, vec!["A", "A1", "B", "B1", "C"]);
}

#[test]
fn outline_edit_without_query_is_free() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);
    // Initial open() ran a synchronous repaint, which queried entries
    // (1 parse). Capture as baseline.
    let after_seed = parse_count(&mut state);
    assert_eq!(
        after_seed, 1,
        "initial parse from open() must be exactly 1 call; got {after_seed}"
    );

    // Twenty edits to the source, no queries / commands between them.
    state
        .lua_host
        .eval(
            Some("outline-edit-burst"),
            r#"
                for _ = 1, 20 do
                    SRC:insert(0, "x")
                end
            "#,
        )
        .expect("edit burst");

    let after_edits = parse_count(&mut state);
    assert_eq!(
        after_edits, after_seed,
        "edits without intervening queries must not reparse; \
         expected count to stay at {after_seed}, got {after_edits}"
    );
}

#[test]
fn outline_edit_in_subtree_content_one_reparse_per_query() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);
    let baseline = parse_count(&mut state);

    // Edit body-A-1 (inside A's body, no headline change).
    state
        .lua_host
        .eval(Some("edit-A-body"), r#"SRC:insert(4, "X")"#)
        .expect("edit");
    let _: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("query");
    let after_query = parse_count(&mut state);
    assert_eq!(
        after_query - baseline,
        1,
        "edit in subtree content + 1 query must be exactly 1 new reparse; delta {}",
        after_query - baseline
    );
}

#[test]
fn outline_edit_to_headline_level_one_reparse_per_query() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);
    let baseline = parse_count(&mut state);

    // Insert a `*` to bump A1's level from 2 to 3.
    state
        .lua_host
        .eval(Some("edit-level"), r#"SRC:insert(22, "*")"#)
        .expect("edit");
    let _: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("query");
    let after_query = parse_count(&mut state);
    assert_eq!(
        after_query - baseline,
        1,
        "headline-level change + 1 query must be exactly 1 reparse; delta {}",
        after_query - baseline
    );
}

#[test]
fn outline_edit_introduces_new_headline_one_reparse_per_query() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);
    let baseline = parse_count(&mut state);

    state
        .lua_host
        .eval(Some("introduce-headline"), r#"SRC:insert(13, "** New\n")"#)
        .expect("introduce");
    let count: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("query");
    let after_query = parse_count(&mut state);
    assert_eq!(
        after_query - baseline,
        1,
        "new-headline insertion + 1 query must be exactly 1 reparse; delta {}",
        after_query - baseline
    );
    assert_eq!(
        count, 6,
        "entry count after insertion must be 6; got {count}"
    );
}

#[test]
fn outline_edit_in_a_does_not_reparse_b_when_querying() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);
    let baseline = parse_count(&mut state);

    state
        .lua_host
        .eval(Some("edit-A"), r#"SRC:insert(4, "Y")"#)
        .expect("edit");

    // Query for B specifically.
    let count_b: i64 = state
        .lua_host
        .lua()
        .load(
            r#"
                local hits = PARSER.query(PH, function(e) return e.title == "B" end)
                return #hits
            "#,
        )
        .eval()
        .expect("query for B");
    assert_eq!(count_b, 1, "B should still match; got {count_b}");

    let after_query = parse_count(&mut state);
    assert_eq!(
        after_query - baseline,
        1,
        "edit-A + query-for-B must produce exactly 1 reparse (A's region only); delta {}",
        after_query - baseline
    );
}

// Pass-2 finding 2 regression: deleting inside a property drawer
// must not lose the enclosing entry's properties.
const PROPS_DRAWER_TEXT: &str = "* A\n\
                                 :PROPERTIES:\n\
                                 :OWNER: alice\n\
                                 :DUE: 2026-12-31\n\
                                 :END:\n\
                                 body of A\n\
                                 * B\nbody-B\n";

#[test]
fn outline_replace_inside_property_drawer_preserves_enclosing_properties() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, PROPS_DRAWER_TEXT);

    // Sanity: A's OWNER is "alice" before the edit.
    let owner_before: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].properties.OWNER")
        .eval()
        .expect("owner before");
    assert_eq!(owner_before, "alice");

    // Replace "alice" with "carol" --- the replacement is inside A's
    // property drawer (in body, after headline_byte_end). After the
    // edit, A still has properties; specifically OWNER is now "carol"
    // and DUE is still "2026-12-31".
    //
    // SAMPLE: "* A\n" (4) + ":PROPERTIES:\n" (13) = byte 17 is start
    // of ":OWNER: alice\n". The "alice" run is bytes 25..30 ("alice"),
    // so replace [25, 30) with "carol".
    state
        .lua_host
        .eval(Some("edit-prop-value"), r#"SRC:replace(25, 30, "carol")"#)
        .expect("replace");

    let owner_after: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].properties.OWNER")
        .eval()
        .expect("owner after");
    assert_eq!(
        owner_after, "carol",
        "OWNER property must reflect the replacement"
    );

    // The DUE property (later in the same drawer) must still be present
    // --- this is the regression the Pass-2 finding flagged.
    let due_after: String = state
        .lua_host
        .lua()
        .load(r#"return PARSER.entries(PH)[1].properties.DUE or "MISSING""#)
        .eval()
        .expect("due after");
    assert_eq!(
        due_after, "2026-12-31",
        "DUE property must survive an in-drawer edit; \
         lost properties indicate the reparse range got truncated"
    );
}

// ---------------------------------------------------------------------------
// Bullet 3 --- properties parse and are queryable
// ---------------------------------------------------------------------------

const PROPS_TEXT: &str = "* Headline with tags :work:urgent:\n\
                          :PROPERTIES:\n\
                          :OWNER: alice\n\
                          :DUE: 2026-12-31\n\
                          :END:\nsome body\n\
                          * Another :work:\n:PROPERTIES:\n:OWNER: bob\n:END:\n\
                          * Untagged\nbody\n";

#[test]
fn outline_tags_parse_and_are_queryable() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, PROPS_TEXT);

    let count: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("entries");
    assert_eq!(count, 3);

    let work_count: i64 = state
        .lua_host
        .lua()
        .load(
            r"
                local hits = PARSER.query(PH, function(e)
                    return e.tagset and e.tagset.work
                end)
                return #hits
            ",
        )
        .eval()
        .expect("query work");
    assert_eq!(work_count, 2);

    let first_title: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].title")
        .eval()
        .expect("first title");
    assert_eq!(first_title, "Headline with tags");
}

#[test]
fn outline_properties_drawer_parses_into_table() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, PROPS_TEXT);

    let owner: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].properties.OWNER")
        .eval()
        .expect("OWNER");
    assert_eq!(owner, "alice");

    let due: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].properties.DUE")
        .eval()
        .expect("DUE");
    assert_eq!(due, "2026-12-31");

    let second_owner: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[2].properties.OWNER")
        .eval()
        .expect("second OWNER");
    assert_eq!(second_owner, "bob");

    let third_props_n: i64 = state
        .lua_host
        .lua()
        .load(
            r"
                local props = PARSER.entries(PH)[3].properties
                local n = 0
                for _ in pairs(props) do n = n + 1 end
                return n
            ",
        )
        .eval()
        .expect("third props count");
    assert_eq!(third_props_n, 0);
}

#[test]
fn outline_query_by_property_value() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, PROPS_TEXT);

    let alice_count: i64 = state
        .lua_host
        .lua()
        .load(
            r#"
                local hits = PARSER.query(PH, function(e)
                    return e.properties.OWNER == "alice"
                end)
                return #hits
            "#,
        )
        .eval()
        .expect("alice");
    assert_eq!(alice_count, 1);
}

// ---------------------------------------------------------------------------
// Bullet 4 --- selective rendering + folding + navigation
// ---------------------------------------------------------------------------

const NAV_TEXT: &str = "* A\nbody-A\n** A1\nbody-A1\n** A2\nbody-A2\n* B\nbody-B\n";

#[test]
fn outline_initial_render_equals_source_when_no_folds() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);
    let vis = visible_text(&mut state);
    assert_eq!(
        vis, NAV_TEXT,
        "with no folds the projection must equal source verbatim"
    );
}

#[test]
fn outline_fold_subtree_collapses_body_to_marker() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Toggle fold for entry "A" (byte 0).
    state
        .lua_host
        .eval(
            Some("fold-A"),
            r#"
                require("pmacs-outline").toggle_fold(H, 0)
            "#,
        )
        .expect("fold A");

    let vis = visible_text(&mut state);
    // A's body, A1, A1's body, A2, A2's body --- all replaced with marker.
    // B and its body remain.
    assert!(
        vis.contains("* A\n"),
        "A's headline must remain visible after fold; got: {vis:?}"
    );
    assert!(
        !vis.contains("body-A1") && !vis.contains("body-A2"),
        "folded subtree's body lines must be hidden; got: {vis:?}"
    );
    assert!(
        !vis.contains("** A1") && !vis.contains("** A2"),
        "folded subtree's child headlines must be hidden; got: {vis:?}"
    );
    assert!(
        vis.contains("...\n"),
        "folded subtree must show a `...` marker; got: {vis:?}"
    );
    assert!(
        vis.contains("* B\n") && vis.contains("body-B"),
        "non-folded sibling B must remain visible; got: {vis:?}"
    );
}

#[test]
fn outline_unfold_restores_subtree() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    state
        .lua_host
        .eval(
            Some("fold-then-unfold"),
            r#"
                local outline = require("pmacs-outline")
                outline.toggle_fold(H, 0)
                outline.toggle_fold(H, 0)
            "#,
        )
        .expect("toggle twice");

    let vis = visible_text(&mut state);
    assert_eq!(
        vis, NAV_TEXT,
        "two toggles must restore the original projection"
    );
}

#[test]
fn outline_visible_buffer_rejects_user_edits() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    let result = state
        .lua_host
        .eval(Some("attempt-edit-visible"), r#"VIS:insert(0, "X")"#);
    assert!(
        result.is_err(),
        "visible buffer must reject user edits; insert succeeded unexpectedly"
    );
    let msg = format!("{:?}", result.unwrap_err());
    assert!(
        msg.contains("read-only") || msg.contains("source buffer instead"),
        "rejection message must point at the source buffer; got: {msg}"
    );
}

#[test]
fn outline_source_edit_propagates_to_visible_after_repaint() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Mutate the source.
    state
        .lua_host
        .eval(Some("mutate-src"), r#"SRC:insert(0, "* New top\n")"#)
        .expect("mutate");

    // Trigger a repaint via the test seam (the same path commands use).
    state
        .lua_host
        .eval(
            Some("force-repaint"),
            r#"
                local te = require("pmacs-outline").__pmacs_outline_test_seam_DO_NOT_USE
                te.repaint(H)
            "#,
        )
        .expect("repaint");

    let vis = visible_text(&mut state);
    assert!(
        vis.starts_with("* New top\n"),
        "repaint must show the source's new prefix; got: {vis:?}"
    );
}

#[test]
fn outline_next_headline_jumps_to_following_headline() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);
    // Cursor starts at the visible buffer's beginning. Source byte 0
    // corresponds to visible line 0 (`* A`).
    state
        .lua_host
        .eval(
            Some("nav-next"),
            r#"pmacs.command.invoke("pmacs-outline.next-headline")"#,
        )
        .expect("next-headline");

    // Without folds, visible == source. Visible line 2 is `** A1`.
    let line = cursor_line(&mut state);
    assert_eq!(
        line, 2,
        "next-headline must land on `** A1` (visible line 2); got {line}"
    );
}

#[test]
fn outline_parent_headline_walks_up_one_level() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Walk down to `** A1`'s line.
    state
        .lua_host
        .eval(
            Some("walk-to-A1"),
            r"
                pmacs.editor.move_down()
                pmacs.editor.move_down()
            ",
        )
        .expect("walk down");
    let pre_line = cursor_line(&mut state);
    assert_eq!(pre_line, 2, "must be on `** A1` line; got {pre_line}");

    state
        .lua_host
        .eval(
            Some("nav-parent"),
            r#"pmacs.command.invoke("pmacs-outline.parent-headline")"#,
        )
        .expect("parent-headline");

    let line = cursor_line(&mut state);
    assert_eq!(line, 0, "parent of A1 is A on visible line 0; got {line}");
}

#[test]
fn outline_fold_subtree_command_collapses_subtree() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Cursor at `* A` (line 0). Run fold-subtree.
    state
        .lua_host
        .eval(
            Some("fold-subtree-cmd"),
            r#"pmacs.command.invoke("pmacs-outline.fold-subtree")"#,
        )
        .expect("fold-subtree");

    let vis = visible_text(&mut state);
    assert!(
        vis.contains("* A\n  ...\n* B\n"),
        "fold-subtree must collapse A's subtree to marker; got: {vis:?}"
    );
}

// ---------------------------------------------------------------------------
// Pass-3 regressions: navigation skips folded entries; structural-edit
// invalidation; fold state survives byte-shift edits.
// ---------------------------------------------------------------------------

#[test]
fn outline_next_headline_skips_hidden_descendants_of_folded_subtree() {
    // Pass-3 finding 1. With A folded, next-headline from `* A` must
    // land on `* B` (the next *visible* headline), not on hidden A1.
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Fold A.
    state
        .lua_host
        .eval(
            Some("fold-A"),
            r"
                require('pmacs-outline').toggle_fold(H, 0)
            ",
        )
        .expect("fold A");

    // Cursor at the visible buffer's first line (`* A`). Run next-headline.
    state
        .lua_host
        .eval(
            Some("nav-next-from-folded-A"),
            r#"pmacs.command.invoke("pmacs-outline.next-headline")"#,
        )
        .expect("next");

    // Visible buffer with A folded is "* A\n  ...\n* B\nbody-B\n".
    // `* B` is on visible line 2.
    let line = cursor_line(&mut state);
    assert_eq!(
        line, 2,
        "next-headline must skip hidden A1/A2 and land on `* B` (visible line 2); \
         got line {line}"
    );
}

#[test]
fn outline_delete_newline_before_headline_invalidates_merged_headline() {
    // Pass-3 finding 2. Deleting the `\n` between two adjacent
    // headlines merges their lines: `* A\n* B\n` becomes `* A* B\n`.
    // The cache must reflect that there's now one headline (with
    // weird title), not two stale ones at the old byte_starts.
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, "* A\n* B\n");

    let n_before: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("before");
    assert_eq!(n_before, 2);

    // Delete the `\n` at byte 3 (between `* A` and `* B`).
    state
        .lua_host
        .eval(Some("delete-nl"), r"SRC:delete(3, 4)")
        .expect("delete nl");

    let entries_n: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("after");
    assert_eq!(
        entries_n, 1,
        "merged headlines must produce exactly one cached entry; got {entries_n}"
    );

    let title: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].title")
        .eval()
        .expect("title");
    assert_eq!(
        title, "A* B",
        "the merged headline's title must reflect the merged line text; got {title:?}"
    );

    let byte_start: i64 = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].byte_start")
        .eval()
        .expect("byte_start");
    assert_eq!(
        byte_start, 0,
        "merged headline starts at byte 0; got {byte_start}"
    );
}

#[test]
fn outline_replace_newline_before_headline_invalidates_merged_headline() {
    // Same shape as the delete case but via replace. Replacing the
    // `\n` with `X` similarly merges the lines.
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, "* A\n* B\n");
    state
        .lua_host
        .eval(Some("replace-nl"), r#"SRC:replace(3, 4, "X")"#)
        .expect("replace nl");

    let entries_n: i64 = state
        .lua_host
        .lua()
        .load(r"return #PARSER.entries(PH)")
        .eval()
        .expect("after");
    assert_eq!(
        entries_n, 1,
        "merged headlines must produce one cached entry; got {entries_n}"
    );
    let title: String = state
        .lua_host
        .lua()
        .load(r"return PARSER.entries(PH)[1].title")
        .eval()
        .expect("title");
    assert_eq!(
        title, "AX* B",
        "merged title must reflect new bytes; got {title:?}"
    );
}

#[test]
fn outline_fold_state_survives_insert_before_folded_entry() {
    // Pass-3 finding 3. Fold an entry, then insert text before it
    // in the source. After repaint, the entry must still be folded.
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, NAV_TEXT);

    // Fold B (which is at byte_start = source byte 25 originally:
    // "* A\nbody-A\n** A1\nbody-A1\n** A2\nbody-A2\n* B\nbody-B\n"
    //  4   + 7      + 6     + 8        + 6     + 8        = 39).
    let b_byte: i64 = state
        .lua_host
        .lua()
        .load(
            r#"
                local entries = PARSER.entries(PH)
                for _, e in ipairs(entries) do
                    if e.title == "B" then return e.byte_start end
                end
                return -1
            "#,
        )
        .eval()
        .expect("find B");
    assert!(b_byte > 0, "must locate B; got {b_byte}");

    state
        .lua_host
        .eval(
            Some("fold-B"),
            &format!("require('pmacs-outline').toggle_fold(H, {b_byte})"),
        )
        .expect("fold B");

    // Confirm B's body is hidden in the projection.
    let vis_before = visible_text(&mut state);
    assert!(
        !vis_before.contains("body-B"),
        "before insertion, B's body should be hidden; got: {vis_before:?}"
    );

    // Insert content at byte 0 of source (well before B).
    state
        .lua_host
        .eval(
            Some("insert-before-B"),
            r#"SRC:insert(0, "* New top\nstuff\n")"#,
        )
        .expect("insert");

    // Force a repaint via the test seam.
    state
        .lua_host
        .eval(
            Some("force-repaint"),
            r#"
                local te = require("pmacs-outline").__pmacs_outline_test_seam_DO_NOT_USE
                te.repaint(H)
            "#,
        )
        .expect("repaint");

    let vis_after = visible_text(&mut state);
    assert!(
        vis_after.contains("* New top\n"),
        "new top headline must appear; got: {vis_after:?}"
    );
    assert!(
        !vis_after.contains("body-B"),
        "B's fold must survive the prefix insertion; got: {vis_after:?}"
    );
    assert!(
        vis_after.contains("...\n"),
        "fold marker must still be present; got: {vis_after:?}"
    );
}

// ---------------------------------------------------------------------------
// Earlier regressions (kept).
// ---------------------------------------------------------------------------

#[test]
fn outline_close_removes_intercepts_and_drops_visible() {
    let (mut state, _c, _u) = editor_with_outline();
    open_outline_with(&mut state, SAMPLE_TEXT);

    state
        .lua_host
        .eval(
            Some("close"),
            r#"
                require("pmacs-outline").close(H)
                _G.SRC_AFTER_CLOSE_VALID = SRC:is_valid()
            "#,
        )
        .expect("close");

    // Source remains valid (caller-owned).
    let src_valid: bool = state
        .lua_host
        .lua()
        .globals()
        .get("SRC_AFTER_CLOSE_VALID")
        .expect("src valid");
    assert!(src_valid, "close() must leave the source buffer alive");

    // Edits to source after close should not panic and should not bump
    // parse counter (parser intercept removed).
    let before = parse_count(&mut state);
    state
        .lua_host
        .eval(Some("edit-after-close"), r#"SRC:insert(0, "Z")"#)
        .expect("edit after close");
    let after = parse_count(&mut state);
    assert_eq!(
        after, before,
        "after close, parser must not run any new parse_region calls"
    );
}

#[test]
fn outline_parser_unit_parses_org_subset() {
    let (mut state, _c, _u) = editor_with_outline();
    state
        .lua_host
        .eval(
            Some("parser-unit-bootstrap"),
            r#"
                local outline = require("pmacs-outline")
                local te = outline.__pmacs_outline_test_seam_DO_NOT_USE
                _G.PR = te.parser.__pmacs_outline_test_parse_region
            "#,
        )
        .expect("bootstrap");

    let level: i64 = state
        .lua_host
        .lua()
        .load(
            r#"
                local entries = PR("* Hello :tag:\n:PROPERTIES:\n:K: v\n:END:\n", 0)
                return entries[1].level
            "#,
        )
        .eval()
        .expect("level");
    assert_eq!(level, 1);

    let title: String = state
        .lua_host
        .lua()
        .load(r#"return PR("* Hello :tag:\n", 0)[1].title"#)
        .eval()
        .expect("title");
    assert_eq!(title, "Hello");

    let n_tags: i64 = state
        .lua_host
        .lua()
        .load(r#"return #PR("* Hello :tag1:tag2:\n", 0)[1].tags"#)
        .eval()
        .expect("tags");
    assert_eq!(n_tags, 2);
}
