use crate::common::harness::EditorTestHarness;
use std::path::PathBuf;

/// Regression test for compose mode typing flicker.
///
/// Loads the real `markdown_compose` plugin, enables compose mode on a document
/// dense with emphasis / links / long wrapped lines, navigates to the middle,
/// types a single character `x`, and captures the screen at three points:
///
///   1. **before** – stable compose view (conceals active, soft-wrapped)
///   2. **mid-frame** – immediately after the buffer edit, before the plugin
///      has responded with fresh decorations
///   3. **after** – once the plugin async response has been processed
///
/// The assertion is strict: the *only* difference between frames in the content
/// area should be the single typed character.  Any other change (wrapping
/// reflow, conceals dropping out, viewport jump) is a flicker regression.
#[test]
fn test_compose_mode_typing_no_flicker() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // -- Build a dense markdown document ──────────────────────────────────
    // Each paragraph has bold, italic, strikethrough, and a link.
    // At 80-col compose width this wraps across multiple screen lines.
    // 60 paragraphs → tall document so we can type in the middle.
    let base_line = "Here is **bold text** and *italic text* and ~~strikethrough~~ \
                     with a [link](#ref) and **more bold** \
                     plus *more italic* ending here.";
    let mut md_content = String::from("# Flicker Regression Test\n\n");
    for i in 0..60 {
        md_content.push_str(&format!("Paragraph {}: {}\n\n", i, base_line));
    }

    // -- Set up project with the markdown_compose plugin ─────────────────
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("flicker_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    // Open the file
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("flicker_test.md");

    // Enable compose mode via command palette
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate (conceals, overlays, view transforms).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            !s.lines().any(|l| l.contains("**"))
        })
        .unwrap();

    // Navigate to the middle of the document (≈ paragraph 20 of 60).
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 40)
        .unwrap();

    // Let conceals settle after cursor movement — wait for full screen stability.
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    // ── Helpers ─────────────────────────────────────────────────────────
    let (content_start, content_end) = harness.content_area_rows();
    let content_rows = content_end - content_start + 1;
    // The scrollbar column is dropped: this test is about the text area
    // settling after an edit, and the track legitimately gains a mark for the
    // unsaved change the keystroke just made.
    let extract_content = |screen: &str| -> Vec<String> {
        screen
            .lines()
            .skip(content_start)
            .take(content_rows)
            .map(|l| {
                let mut chars: Vec<char> = l.chars().collect();
                chars.pop();
                chars.into_iter().collect()
            })
            .collect()
    };

    /// Diff two equal-length row vectors.  Returns indices + both lines for
    /// every row that differs.
    fn diff_rows(a: &[String], b: &[String]) -> Vec<(usize, String, String)> {
        a.iter()
            .zip(b.iter())
            .enumerate()
            .filter(|(_, (la, lb))| la != lb)
            .map(|(i, (la, lb))| (i, la.clone(), lb.clone()))
            .collect()
    }

    fn format_diffs(diffs: &[(usize, String, String)]) -> String {
        diffs
            .iter()
            .map(|(i, a, b)| {
                format!(
                    "  row {:>2}: {:?}\n      → : {:?}",
                    i,
                    a.trim_end(),
                    b.trim_end()
                )
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    // ── Capture the stable "before" screen ──────────────────────────────
    let before_screen = harness.screen_to_string();
    let before_content = extract_content(&before_screen);

    // ── Validate compose mode is fully active before we type ────────────
    // 1) Emphasis markers are concealed: no ** should appear on non-cursor lines.
    let bold_lines: Vec<_> = before_content
        .iter()
        .enumerate()
        .filter(|(_, l)| l.contains("**"))
        .map(|(i, l)| (i, l.clone()))
        .collect();
    assert!(
        bold_lines.len() <= 1,
        "Compose mode should conceal ** markers (found {} lines with **).\n\
         Is the plugin loaded and compose enabled?\n\
         Lines with **:\n{}",
        bold_lines.len(),
        bold_lines
            .iter()
            .map(|(i, l)| format!("  row {}: {:?}", i, l.trim_end()))
            .collect::<Vec<_>>()
            .join("\n"),
    );
    // 2) Link syntax is concealed: no raw ]( on non-cursor lines.
    let link_lines: Vec<_> = before_content
        .iter()
        .enumerate()
        .filter(|(_, l)| l.contains("]("))
        .map(|(i, l)| (i, l.clone()))
        .collect();
    assert!(
        link_lines.len() <= 1,
        "Compose mode should conceal link syntax (found {} lines with ]().\n\
         Lines with ](:\n{}",
        link_lines.len(),
        link_lines
            .iter()
            .map(|(i, l)| format!("  row {}: {:?}", i, l.trim_end()))
            .collect::<Vec<_>>()
            .join("\n"),
    );
    // 3) Soft wrapping is active: paragraphs should span multiple screen rows.
    //    Each base-line is ~130 chars of visible text; at 80-col compose width
    //    that must wrap, producing continuation rows that start with a lowercase
    //    letter or whitespace (not "Paragraph N:").  Count those continuation
    //    rows — there must be a healthy number of them.
    let continuation_rows = before_content
        .iter()
        .filter(|l| {
            let t = l.trim_start();
            !t.is_empty()
                && !t.starts_with("Paragraph")
                && !t.starts_with('#')
                && !t.starts_with("Flicker")
        })
        .count();
    assert!(
        continuation_rows >= 5,
        "Compose soft-wrapping should produce continuation rows (found {}).  \
         Is compose soft-wrapping active?\n\
         Content:\n{}",
        continuation_rows,
        before_content.join("\n"),
    );

    // ── Type a single character, then wait for the renderer to settle ──
    //
    // Step 1: buffer edit. The plugin sees lines_changed on its thread
    // and asynchronously refreshes its soft breaks and conceals.
    harness
        .editor_mut()
        .handle_key(KeyCode::Char('x'), KeyModifiers::NONE)
        .unwrap();

    // Step 2: semantic wait for the post-edit transient to clear.
    //
    // The previous version of this test captured a "mid-frame" with a
    // bare `harness.render()` immediately after the keypress. That
    // bet on the plugin's async reply arriving
    // inside the same render call (Editor::render drains pending
    // plugin commands non-blockingly partway through). On Linux the
    // QuickJS round-trip happens to land in time; on Windows the
    // process / thread scheduling means the response usually misses
    // the window and the bare render shows raw markdown — flake.
    //
    // CONTRIBUTING.md §Testing rule 3 forbids time-sensitive captures.
    // Wait until the renderer settles instead: at most one line shows
    // raw `**` markers (the cursor's own line is allowed to reveal
    // them, since the conceal-on-cursor exception keeps that row's
    // markup visible while editing).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.lines().filter(|l| l.contains("**")).count() <= 1
        })
        .unwrap();
    let after_screen = harness.screen_to_string();
    let after_content = extract_content(&after_screen);

    // ── Strict diff: only the typed character should change ─────────────
    let before_vs_after = diff_rows(&before_content, &after_content);

    eprintln!("=== BEFORE → AFTER diffs ({}) ===", before_vs_after.len());
    eprintln!("{}", format_diffs(&before_vs_after));

    // before → after: only the typed character should differ. This
    // catches the regression the original test was guarding — if
    // conceals or wrapping permanently drop out after an edit, the
    // diff will explode here. The mid-frame transient is intentionally
    // not asserted (see comment above): it depends on plugin response
    // speed which varies per platform.
    assert!(
        before_vs_after.len() <= 1,
        "FLICKER: before→after differs on {} content rows — expected at most 1.  \
         Plugin failed to fully restore the compose view after the edit.\n\
         Diffs:\n{}\n\n\
         Full before:\n{}\n\n\
         Full after:\n{}",
        before_vs_after.len(),
        format_diffs(&before_vs_after),
        before_content.join("\n"),
        after_content.join("\n"),
    );
    if let Some((_, _old, new)) = before_vs_after.first() {
        assert!(
            new.contains('x'),
            "The single changed row in after-frame should contain the typed 'x', got: {:?}",
            new.trim_end(),
        );
    }
}

/// Test that markdown files can be opened and rendered
#[test]
fn test_markdown_file_open() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    // Get the path to the test markdown file
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    // Open the markdown file
    harness.open_file(&md_path).unwrap();

    // Verify file is loaded
    harness.render().unwrap();
    harness.assert_screen_contains("markdown_sample.md");

    // Verify content is visible
    harness.assert_screen_contains("Markdown Compose Mode Test");
}

/// Test buffer content API (getBufferText with start and end)
#[test]
fn test_buffer_content_api() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();

    // Get buffer content to verify it's not empty
    let content = harness.get_buffer_content().unwrap();
    assert!(!content.is_empty(), "Buffer content should not be empty");
    assert!(
        content.contains("# Markdown Compose Mode Test"),
        "Should contain header"
    );
}

/// Test that viewport info is available
#[test]
fn test_viewport_info_available() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Just verify the file loads and renders without crashing
    // The plugin's getViewport() call happens during render
    harness.assert_screen_contains("Markdown");
}

/// Test markdown compose mode toggle command
#[test]
fn test_markdown_compose_toggle() {
    use crossterm::event::{KeyCode, KeyModifiers};

    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    // Get the path to the test markdown file
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    // Open the markdown file
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify initial render shows content
    harness.assert_screen_contains("Markdown Compose Mode Test");

    // Try to invoke command palette
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();

    // Command palette should be visible
    // Note: This test is basic - a full test would search for and execute
    // the "Markdown: Toggle Compose" command
}

/// Test that plugin doesn't crash on empty buffer
#[test]
fn test_empty_buffer_handling() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    // Create empty markdown file
    let temp_dir = tempfile::TempDir::new().unwrap();
    let empty_md = temp_dir.path().join("empty.md");
    std::fs::write(&empty_md, "").unwrap();

    harness.open_file(&empty_md).unwrap();
    harness.render().unwrap();

    // Should render without crashing
    harness.assert_screen_contains("empty.md");
}

/// Test that plugin handles non-markdown files correctly
#[test]
fn test_non_markdown_file_ignored() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    // Create a non-markdown file
    let temp_dir = tempfile::TempDir::new().unwrap();
    let txt_file = temp_dir.path().join("test.txt");
    std::fs::write(&txt_file, "This is not markdown").unwrap();

    harness.open_file(&txt_file).unwrap();
    harness.render().unwrap();

    // Should render normally without trying to apply markdown processing
    harness.assert_screen_contains("test.txt");
}

/// Test that markdown headers are properly styled
#[test]
fn test_markdown_header_rendering() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify the header is visible in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("# Markdown Compose Mode Test"));
    assert!(buffer_content.contains("## Features"));
    assert!(buffer_content.contains("### Code Blocks"));
}

/// Test markdown list rendering
#[test]
fn test_markdown_list_rendering() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify list items are in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("- Soft breaks for paragraph wrapping"));
    assert!(buffer_content.contains("1. First ordered item"));
    assert!(buffer_content.contains("- [ ] Unchecked task"));
    assert!(buffer_content.contains("- [x] Checked task"));
}

/// Test markdown code block rendering
#[test]
fn test_markdown_code_block_rendering() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify code blocks are in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("```rust"));
    assert!(buffer_content.contains("fn main()"));
    assert!(buffer_content.contains("println!"));
}

/// Test markdown inline styles
#[test]
fn test_markdown_inline_styles() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify inline styles are in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("**bold**"));
    assert!(buffer_content.contains("*italic*"));
    assert!(buffer_content.contains("`inline code`"));
    assert!(buffer_content.contains("~~strikethrough~~"));
}

/// Test markdown links
#[test]
fn test_markdown_links() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify links are in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("[Links to resources]"));
    assert!(buffer_content.contains("[Fresh Editor]"));
}

/// Test markdown block quotes
#[test]
fn test_markdown_block_quotes() {
    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let md_path = PathBuf::from(manifest_dir)
        .join("tests")
        .join("fixtures")
        .join("markdown_sample.md");

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify block quotes are in the buffer
    let buffer_content = harness.get_buffer_content().unwrap();
    assert!(buffer_content.contains("> This is a block quote."));
}

/// Test that disabling compose mode doesn't blank the view
#[test]
fn test_compose_mode_disable_preserves_content() {
    use crossterm::event::{KeyCode, KeyModifiers};

    let mut harness = EditorTestHarness::new(100, 30).unwrap();

    // Create a simple markdown file
    let temp_dir = tempfile::TempDir::new().unwrap();
    let md_path = temp_dir.path().join("test.md");
    std::fs::write(
        &md_path,
        "# Test Header\n\nSome **bold** text.\n\n- List item 1\n- List item 2\n",
    )
    .unwrap();

    // Open the markdown file
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Verify initial content is visible
    harness.assert_screen_contains("Test Header");
    harness.assert_screen_contains("bold");

    // Open command palette and toggle compose mode ON
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness.type_text("Markdown: Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    // Should show compose mode enabled status
    // Content should still be visible
    harness.assert_screen_contains("Test Header");

    // Toggle compose mode OFF
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness.type_text("Markdown: Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.render().unwrap();

    // CRITICAL: Content should still be visible after disabling compose mode
    harness.assert_screen_contains("Test Header");
    harness.assert_screen_contains("bold");
    harness.assert_screen_contains("List item");
}

/// Regression test for issue #1789.
///
/// Compose mode wraps a long paragraph using soft breaks computed by the
/// plugin. The Rust renderer's wrap pass reserves the last column for the
/// EOL cursor, so the plugin must use the same reservation. Without the
/// reservation, the plugin produces a "perfect-fit" line at width N that
/// the renderer then re-wraps at width N-1 — splitting off the trailing
/// word into a single-word "orphan" visual row.
///
/// The test sweeps a range of viewport widths one column at a time,
/// resizing the harness in-place so the same compose session is reused.
/// The semantic wait condition is on the paragraph rows themselves
/// (rather than the whole screen) so a transient stable status bar can't
/// short-circuit the stability check while soft breaks are still being
/// recomputed asynchronously after a resize.
#[test]
fn test_compose_wrap_no_single_word_orphan() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crossterm::event::{KeyCode, KeyModifiers};

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();
    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("orphan.md");
    let paragraph = "A piece tree data structure represents this file. \
                     Some of the data may be in memory while the rest is \
                     pointed at the disk. The piece tree provides an iterator \
                     that walks in linear offset order over nodes of the tree, \
                     yielding chunks of contiguous content longer.";
    std::fs::write(&md_path, format!("# Test\n\n{paragraph}\n")).unwrap();

    // Sweep range covers the widths reported in the issue (90, 105, 110)
    // with margin on either side so off-by-one regressions are caught.
    // The harness is resized down one column at a time within this range.
    let max_width: u16 = 120;
    let min_width: u16 = 80;
    let mut harness = EditorTestHarness::with_config_and_working_dir(
        max_width,
        30,
        Default::default(),
        project_root,
    )
    .unwrap();
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode once. The same buffer/session is reused across
    // resizes — the soft-break pipeline reruns on `viewport_changed`.
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    /// Find rows belonging to the paragraph by skipping the heading and
    /// empty rows above the paragraph, then taking until the next blank
    /// row. Trims each row so trailing renderer padding doesn't change
    /// equality of "the paragraph hasn't moved yet" comparisons.
    fn paragraph_rows(harness: &crate::common::harness::EditorTestHarness) -> Vec<String> {
        let screen = harness.screen_to_string();
        let (content_start, content_end) = harness.content_area_rows();
        screen
            .lines()
            .skip(content_start)
            .take(content_end - content_start + 1)
            .skip_while(|l| {
                let t = l.trim_start();
                t.is_empty() || t.starts_with('#') || t.starts_with("Test")
            })
            .take_while(|l| !l.trim().is_empty())
            .map(|l| l.trim_end().to_string())
            .collect()
    }

    for width in (min_width..=max_width).rev() {
        if width != max_width {
            harness.resize(width, 30).unwrap();
        }

        // Semantic stability on the paragraph itself: poll until two
        // consecutive renders produce the same paragraph rows AND the
        // first row is non-trivial. This is stricter than relying on
        // overall screen stability — async soft-break recomputation after
        // a resize might land between two screen polls that otherwise
        // looked identical (status bar untouched). Tying stability to the
        // paragraph rows rules that out.
        let mut prev = Vec::<String>::new();
        harness
            .wait_until(|h| {
                let cur = paragraph_rows(h);
                let stable =
                    !cur.is_empty() && cur == prev && cur.first().is_some_and(|r| !r.is_empty());
                prev = cur;
                stable
            })
            .unwrap();

        let rows = paragraph_rows(&harness);
        assert!(
            rows.len() >= 2,
            "width {width}: paragraph should wrap onto multiple visual rows. \
             Screen:\n{}",
            harness.screen_to_string(),
        );

        // No NON-LAST paragraph row may hold a single word — that's the
        // orphan bug from issue #1789. The last visual row of a paragraph
        // can naturally be short under any greedy wrap (e.g. just the
        // sentence terminator) and is not what this fix addresses; that's
        // a separate balancing concern (Knuth–Plass).
        for (i, row) in rows.iter().enumerate().take(rows.len() - 1) {
            let words: Vec<&str> = row.split_whitespace().collect();
            assert!(
                words.len() >= 2,
                "width {width}: visual row {i} contains a single-word orphan {:?}.\n\
                 Issue #1789: wrap budget must match the renderer's reservation.\n\
                 Paragraph rows:\n{}",
                words.first().copied().unwrap_or(""),
                rows.join("\n"),
            );
        }
    }
}

/// Test visual cursor movement through soft-wrapped lines and auto-expose /
/// re-conceal of markup when the cursor enters / leaves a line.
///
/// Auto-expose in the markdown compose plugin is **span-level**: concealed
/// syntax markers (like `**`) are only revealed when the cursor byte offset
/// falls within the specific emphasis/link span.  To test this reliably we
/// place `**Bold**` at the very start of a line so that arriving at column 0
/// via a Down-arrow puts the cursor inside the bold span.
#[test]
fn test_compose_mode_visual_cursor_and_auto_expose() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // -- Build test document ──────────────────────────────────────────────
    // Line 4 starts with **Bold** so cursor at col 0 lands inside the span.
    let md_content = "\
# Test Document

Short plain line here.

**Bold** word and *italic* and ~~strikethrough~~ plus a [link](#ref) in one line.

Paragraph five is deliberately very long so it will definitely soft-wrap at eighty columns wide when compose mode is active and the compose width is set to eighty characters, which means this text will span at least two or three visual lines on the screen giving us room to test visual cursor movement.

Another line with **bold text** for testing.
";

    // -- Set up project with the markdown_compose plugin ─────────────────
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("cursor_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    // Open the file
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("cursor_test.md");

    // Enable compose mode via command palette
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate (conceals, overlays, view transforms).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            !s.lines().any(|l| l.contains("**"))
        })
        .unwrap();

    // ── 1. Verify conceals are active ───────────────────────────────────
    harness.render().unwrap();
    let screen = harness.screen_to_string();
    let bold_marker_lines: Vec<_> = screen.lines().filter(|l| l.contains("**")).collect();
    assert!(
        bold_marker_lines.is_empty(),
        "Conceals should hide ** on non-cursor lines (found {} lines with **)",
        bold_marker_lines.len(),
    );

    // ── 2. Navigate to "Short plain line here." (line 2) ────────────────
    //    Lines: 0=heading, 1=blank, 2=Short plain…
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();

    // ── 3. Auto-expose: ** should NOT be visible yet ────────────────────
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("**"))
        .unwrap();

    // ── 4. Move down to the bold line (line 4) ──────────────────────────
    //    From line 2, down 2 → line 4 (the emphasis/link line).
    //    Since **Bold** starts at column 0, the cursor (at col 0) lands
    //    inside the bold span, triggering auto-expose.
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();

    // Auto-expose: ** SHOULD now be visible because cursor is within the span
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("**"))
        .unwrap();
    let screen_on_bold = harness.screen_to_string();
    assert!(
        screen_on_bold.lines().any(|l| l.contains("**")),
        "After moving cursor into the **Bold** span, raw ** should be exposed (auto-expose)\n\
         Screen:\n{}",
        screen_on_bold,
    );

    // ── 5. Re-conceal: move cursor past the bold line ───────────────────
    //    Move down 2 to get into the long paragraph (line 6)
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();

    // The old bold line should be re-concealed — no raw ** on it.
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("**"))
        .unwrap();
    let screen_after_bold = harness.screen_to_string();
    let bold_after: Vec<_> = screen_after_bold
        .lines()
        .filter(|l| l.contains("**"))
        .collect();
    assert!(
        bold_after.is_empty(),
        "After leaving the bold line, ** should be re-concealed (found {} lines with **)",
        bold_after.len(),
    );

    // ── 6. Visual cursor movement through soft-wrapped paragraph ────────
    //    We should now be on the long paragraph (line 6).
    //    Record screen cursor position, press Down, and verify we moved
    //    exactly one visual row while staying in the same logical paragraph.
    //
    //    Use wait_until_stable to ensure compose rendering has fully settled
    //    before reading cursor positions (avoids flakiness on Windows CI).
    harness.wait_until_stable(|_| true).unwrap();
    let pos_before = harness.screen_cursor_position();
    let byte_before = harness.cursor_position();

    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness
        .wait_until_stable(|h| h.cursor_position() != byte_before)
        .unwrap();
    let pos_after_one = harness.screen_cursor_position();
    let byte_after_one = harness.cursor_position();

    // Screen row should advance by 1 (visual line movement)
    assert_eq!(
        pos_after_one.1,
        pos_before.1 + 1,
        "Down arrow should move exactly one visual row (before={:?}, after={:?})",
        pos_before,
        pos_after_one,
    );

    // Byte offset should still be within the long paragraph.
    let content = harness.get_buffer_content().unwrap();
    let para_start = content.find("Paragraph five").unwrap();
    let para_end = content[para_start..].find("\n\n").unwrap() + para_start;
    assert!(
        byte_after_one >= para_start && byte_after_one <= para_end,
        "After one Down inside wrapped paragraph, cursor byte {} should be within paragraph [{}, {}]",
        byte_after_one, para_start, para_end,
    );

    // Press Down again — should advance one more visual row
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness
        .wait_until_stable(|h| h.cursor_position() != byte_after_one)
        .unwrap();
    let pos_after_two = harness.screen_cursor_position();
    let byte_after_two = harness.cursor_position();

    assert_eq!(
        pos_after_two.1,
        pos_after_one.1 + 1,
        "Second Down should move one more visual row (after1={:?}, after2={:?})",
        pos_after_one,
        pos_after_two,
    );
    assert!(
        byte_after_two >= para_start && byte_after_two <= para_end,
        "After two Downs inside wrapped paragraph, cursor byte {} should still be within paragraph [{}, {}]",
        byte_after_two, para_start, para_end,
    );
}

/// Test that long lines with no whitespace are force-wrapped by the Rust
/// wrapping transform at the viewport/content width.
///
/// The markdown compose plugin can only insert soft breaks at spaces.  When a
/// line has no whitespace at all (e.g. a long URL or a run of characters),
/// the plugin emits no soft breaks.  The `apply_wrapping_transform()` in
/// `split_rendering.rs` must force-break the line grapheme-by-grapheme at
/// the available content width so the text doesn't overflow the terminal.
#[test]
fn test_compose_mode_no_whitespace_line_wrapping() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // A 200-character run with no spaces — well past the 80-col compose/viewport width.
    let long_word: String = "abcdefghij".repeat(20); // 200 chars, no spaces
    let md_content = format!(
        "# Wrap Test\n\nShort line.\n\n{}\n\nEnd marker line.\n",
        long_word
    );

    // -- Set up project with the markdown_compose plugin ─────────────────
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("wrap_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("wrap_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (soft-wrapping applied).
    harness
        .wait_until_stable(|h| {
            // The long word should be split across multiple screen rows
            let s = h.screen_to_string();
            s.lines().filter(|l| l.contains("abcdefghij")).count() >= 3
        })
        .unwrap();

    let screen = harness.screen_to_string();

    // The 200-char word must be split across multiple screen rows.
    // Count how many screen rows contain a substring of the long word.
    // Each row is at most ~78 chars of content (80 minus gutter/scrollbar),
    // so 200 chars needs at least 3 rows.
    let fragment = &long_word[..10]; // "abcdefghij" — appears in every chunk
    let rows_with_fragment: Vec<_> = screen.lines().filter(|l| l.contains(fragment)).collect();
    assert!(
        rows_with_fragment.len() >= 3,
        "200-char word should span at least 3 screen rows (found {}). \
         The wrapping transform should force-break lines without whitespace.\n\
         Screen:\n{}",
        rows_with_fragment.len(),
        screen,
    );

    // The "End marker line." should still be visible below the wrapped word.
    harness.assert_screen_contains("End marker line");
}

/// Test that mouse-wheel scrolling in compose mode can reach the very bottom
/// of a document.  Uses a copy of the project README.md as a realistic sample.
#[test]
fn test_compose_mode_mouse_scroll_to_bottom() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // -- Set up project with README.md and the markdown_compose plugin ───
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    // Copy README.md into the test project
    let source_readme = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .join("README.md");
    let dest_readme = project_root.join("README.md");
    std::fs::copy(&source_readme, &dest_readme).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&dest_readme).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("README.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (table conceals visible in README).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") || s.contains("─") || !s.contains("**")
        })
        .unwrap();

    // Scroll down aggressively with mouse wheel.
    // README.md is ~278 lines; with compose wrapping at 80 cols it will be
    // even more visual lines.  Each scroll event moves ~3 lines, so 200
    // scroll events should be more than enough to reach the bottom.
    let (content_start, content_end) = harness.content_area_rows();
    let mid_row = ((content_start + content_end) / 2) as u16;
    for _ in 0..200 {
        harness.mouse_scroll_down(40, mid_row).unwrap();
    }

    // Wait for the last line of the README to be visible after scrolling.
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("GPL-2.0"))
        .unwrap();

    // The very last line of the README is "...GNU General Public License v2.0 (GPL-2.0)."
    // It should be visible on screen after scrolling to the bottom.
    let screen = harness.screen_to_string();
    assert!(
        screen.contains("GPL-2.0"),
        "After scrolling to the bottom, the last line of the README should be visible.\n\
         Screen:\n{}",
        screen,
    );
}

/// Regression test for two compose-mode mouse-wheel scrolling bugs:
///
/// 1. **Wheel absorbed at long-wrap item boundaries.** With a numbered/bullet
///    list whose items wrap to many visual rows under the markdown plugin's
///    hanging-indent wrapping, every wheel event that landed on the start of
///    the next item produced no movement — the scroll viewport stayed put.
///
/// 2. **Bottom half empty (mouse stops short of EOF).** With a long-wrap
///    list at the very end of the document, the wheel-scroll math clamped
///    the viewport short of the keyboard's `Ctrl+End` position, leaving the
///    bottom of the visible area as `~` filler rows.
///
/// Both bugs share a root cause: `Viewport::scroll_down_visual` and friends
/// counted visual rows using `wrap_line` on raw source text, which doesn't
/// know about the plugin-injected hanging indent ("1. " marker → 3-column
/// continuation indent). The plugin's `addSoftBreak` markers describe the
/// *actual* on-screen wrapping, so those markers are now consulted by the
/// scroll math (see `EditorState::collect_soft_break_positions`).
///
/// The test scrolls a small list-only fixture wheel-by-wheel and asserts
/// that (a) every wheel makes monotonic progress (no stuck/absorbed
/// events) and (b) the EOF marker becomes visible within the expected
/// number of wheels.
#[test]
fn test_compose_mode_mouse_wheel_long_list_progresses_to_eof() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Build a tiny markdown file: 5 numbered list items, each a 99-word
    // single line. Under markdown_compose at 60 columns each item wraps to
    // roughly 13 visual rows (with a 3-column hanging indent). The bug
    // described above made the wheel "absorb" one event per item start, so
    // reaching EOF took materially more wheels than the keyboard.
    let mut md = String::from("# Test\n\n");
    for i in 1..=5 {
        md.push_str(&format!("{}. Item {}: ", i, i));
        for w in 1..=99 {
            if w > 1 {
                md.push(' ');
            }
            md.push_str(&format!("word{}", w));
        }
        md.push('\n');
    }
    md.push_str("\nEOF_MARKER\n");

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("scroll_repro.md");
    std::fs::write(&md_path, md).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(60, 24, Default::default(), project_root)
            .unwrap();
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("scroll_repro.md");

    // Enable compose mode.
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose decorations (the hanging-indent continuation rows
    // start with at least three leading spaces).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("   word")
        })
        .unwrap();

    // Scroll wheel-by-wheel from the top, asserting monotonic progress and
    // requiring EOF_MARKER to appear within a generous bound.
    let (content_start, content_end) = harness.content_area_rows();
    let mid_row = ((content_start + content_end) / 2) as u16;

    // Visual cost of the document under the plugin's wrapping:
    //   header (2 rows) + 5 items * ~14 rows + EOF (~3 rows) ≈ 75 rows.
    // Each wheel advances 3 visual rows, so 25 wheels suffice; 60 leaves
    // generous slack. With the bug, each item-start absorbed one wheel —
    // 5 items × 1 = 5 extra wheels, *and* the apply_visual_scroll_limit
    // clamp could refuse the last few wheels entirely, so this test would
    // fail before the fix.
    const MAX_WHEELS: usize = 60;

    let mut prev_top: Option<String> = None;
    let mut stuck_count = 0usize;
    let mut wheels_used = 0usize;
    for i in 1..=MAX_WHEELS {
        harness.mouse_scroll_down(40, mid_row).unwrap();
        wheels_used = i;

        let screen = harness.screen_to_string();
        if screen.contains("EOF_MARKER") {
            break;
        }

        // Track per-wheel progress. The top of the visible content area must
        // not stay identical for more than one wheel in a row — that would
        // be the "absorbed wheel" symptom from the original bug report.
        let lines: Vec<&str> = screen.lines().collect();
        let top = lines
            .get(content_start)
            .copied()
            .unwrap_or("")
            .trim_end()
            .to_string();
        if Some(&top) == prev_top.as_ref() {
            stuck_count += 1;
            assert!(
                stuck_count < 2,
                "Mouse wheel produced no scroll progress for {stuck_count} consecutive events \
                 at wheel #{i}. Top of viewport: {top:?}\nFull screen:\n{screen}"
            );
        } else {
            stuck_count = 0;
        }
        prev_top = Some(top);
    }

    let final_screen = harness.screen_to_string();
    assert!(
        final_screen.contains("EOF_MARKER"),
        "EOF_MARKER never became visible after {wheels_used} wheel events; \
         the wheel scroll appears stuck short of EOF.\nFinal screen:\n{final_screen}"
    );
}

/// Test that HTML entities are rendered as their Unicode characters in compose mode.
///
/// Named entities like `&amp;`, `&mdash;`, `&nbsp;` and numeric entities like
/// `&#169;` should be concealed and replaced with the corresponding Unicode
/// character when compose mode is active.
#[test]
fn test_compose_mode_html_entity_rendering() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Entity Test

Ampersand: &amp; dash: &mdash; space:&nbsp;here numeric: &#169;
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("entity_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("entity_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate: HTML entities should be concealed.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            !s.contains("&amp;") && s.contains("Ampersand")
        })
        .unwrap();

    // Move cursor away from the entity line so conceals are active
    harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();

    // Wait for conceals to settle after cursor move
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("&amp;"))
        .unwrap();

    let screen = harness.screen_to_string();

    // Raw entity syntax should be concealed (not visible)
    assert!(
        !screen.contains("&amp;"),
        "Raw &amp; should be concealed in compose mode.\nScreen:\n{}",
        screen,
    );
    assert!(
        !screen.contains("&mdash;"),
        "Raw &mdash; should be concealed in compose mode.\nScreen:\n{}",
        screen,
    );

    // Rendered replacements should be visible
    assert!(
        screen.contains("&") && screen.contains("Ampersand"),
        "Rendered & should be visible for &amp;\nScreen:\n{}",
        screen,
    );
    assert!(
        screen.contains("\u{2014}"),
        "Rendered \u{2014} (em dash) should be visible for &mdash;\nScreen:\n{}",
        screen,
    );
}

/// Test that table columns are aligned (padded to equal widths) in compose mode.
///
/// Given an uneven table, the box-drawing pipe positions should line up
/// consistently across all rows.
#[test]
fn test_compose_mode_table_alignment() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};
    use std::time::Duration;

    init_tracing_from_env();

    let md_content = "\
# Table Test

| Name | Age |
|---|---|
| Alice | 25 |
| Bob | 1000000 |

End of table test.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("table_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (table conceals visible) then stabilize.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    // Navigate cursor INTO the table (onto a data row) — conceals should
    // remain active even with cursor on the line.
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 4)
        .unwrap();

    // Wait for conceals to settle after cursor movement.
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    let screen = harness.screen_to_string();

    // Box-drawing characters should be visible even with cursor on a table row
    assert!(
        screen.contains("│"),
        "Box-drawing pipe │ should be visible in compose mode table.\nScreen:\n{}",
        screen,
    );
    assert!(
        screen.contains("─"),
        "Box-drawing dash ─ should be visible in compose mode table.\nScreen:\n{}",
        screen,
    );

    // Verify column alignment: non-cursor table rows (data AND separator)
    // should have their column delimiters at the same positions.
    // The cursor row is intentionally unpadded (raw text shown for correct
    // cursor positioning), so exclude it from alignment checks.
    // Data rows use │, separator uses ├/┼/┤ — extract positions of all of these.
    let all_table_rows: Vec<&str> = screen
        .lines()
        .filter(|l| l.contains('│') || l.contains('┼'))
        .collect();
    assert!(
        all_table_rows.len() >= 3,
        "Should have at least 3 aligned table rows (header + separator + 1 data). Found {}.\nScreen:\n{}",
        all_table_rows.len(),
        screen,
    );

    // Extract visual column positions of delimiters (│, ┼, ├, ┤) in each row.
    // Use character index (enumerate), not byte offset (char_indices), because
    // box-drawing chars are multi-byte UTF-8.
    let delimiter_positions: Vec<Vec<usize>> = all_table_rows
        .iter()
        .map(|row| {
            row.chars()
                .enumerate()
                .filter(|(_, c)| matches!(*c, '│' | '┼' | '├' | '┤'))
                .map(|(i, _)| i)
                .collect()
        })
        .collect();

    // Non-cursor rows should have delimiters at the same positions.
    // The cursor row (Alice) has no padding because raw text is shown for
    // correct cursor positioning, so its delimiters may be at different
    // positions — exclude it from the alignment check.
    // TODO: Ideally the cursor row should still be padded/aligned; skipping
    // padding is a workaround for the segment-conceal cursor positioning bug.
    let non_cursor_rows: Vec<_> = all_table_rows
        .iter()
        .enumerate()
        .filter(|(_, row)| !row.contains("Alice"))
        .collect();
    assert!(
        non_cursor_rows.len() >= 3,
        "Should have at least 3 non-cursor table rows.\nScreen:\n{}",
        screen,
    );
    let reference = &delimiter_positions[non_cursor_rows[0].0];
    for &(i, _) in &non_cursor_rows {
        assert_eq!(
            &delimiter_positions[i],
            reference,
            "Table row {} has delimiters at {:?} but reference has {:?} — columns are misaligned.\n\
             Table rows:\n{}",
            i,
            &delimiter_positions[i],
            reference,
            all_table_rows.join("\n"),
        );
    }

    // Verify cursor can navigate UP through and beyond the table.
    // Move cursor to the first table row, then up past it.
    harness
        .send_key_repeat(KeyCode::Up, KeyModifiers::NONE, 4)
        .unwrap();
    for _ in 0..5 {
        harness.process_async_and_render().unwrap();
        std::thread::sleep(Duration::from_millis(50));
        harness.advance_time(Duration::from_millis(50));
    }

    harness.render().unwrap();
    let screen_after_up = harness.screen_to_string();

    // Should have navigated above the table — "Table Test" heading should
    // be near the cursor (visible on screen, and cursor on or above it).
    assert!(
        screen_after_up.contains("Table Test"),
        "After pressing Up 4 times from inside the table, the heading above \
         should be reachable.\nScreen:\n{}",
        screen_after_up,
    );
}

/// Test that cursor navigation through a table in compose mode doesn't produce
/// ghost/duplicate cursors and that the cursor can move up past the first
/// table row to reach content above the table.
///
/// Reproduces two bugs:
/// 1. Two cursors visible while scrolling cursor through a table
/// 2. Cursor gets stuck on the first table row — can't move up past it
#[test]
fn test_compose_mode_table_cursor_navigation() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Document with text before the table so we can test navigating out
    let md_content = "\
# Heading

Some text above the table.

| Name | Age |
|---|---|
| Alice | 25 |
| Bob | 1000000 |

Text below the table.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_nav_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("table_nav_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (table conceals visible).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();

    // Navigate to the last data row of the table: "| Bob | 1000000 |"
    // Document lines: 0=heading, 1=blank, 2=text, 3=blank,
    //                 4=header, 5=sep, 6=Alice, 7=Bob, 8=blank, 9=text below
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 7)
        .unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("│"))
        .unwrap();

    // ── Bug 1: Only one cursor should be visible ─────────────────────────
    // When the cursor is on a table row, no other cell should have cursor-like
    // styling (REVERSED or inactive-cursor background). Box-drawing characters
    // from table conceals must not look like cursors.
    harness.render().unwrap();

    let cursors = harness.find_all_cursors();
    assert_eq!(
        cursors.len(),
        1,
        "Should have exactly 1 cursor on screen, found {}. \
         Ghost cursor detected — table conceal replacement chars are \
         getting cursor-like styling. Cursors: {:?}",
        cursors.len(),
        cursors,
    );

    // Check single cursor on each row as we move up through the table
    for row_label in &["Alice row", "separator", "header row"] {
        harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
        harness
            .wait_until_stable(|h| h.screen_to_string().contains("│"))
            .unwrap();

        let cursors = harness.find_all_cursors();
        // eprintln!("Cursors on {}: {:?}", row_label, cursors);
        assert_eq!(
            cursors.len(),
            1,
            "On {}: should have exactly 1 cursor on screen, found {}. \
             Ghost cursor detected. Cursors: {:?}",
            row_label,
            cursors.len(),
            cursors,
        );
    }

    // ── Bug 2: Cursor should escape above the table ──────────────────────
    // We're now on the header row (| Name | Age |).  Record screen position.
    let pos_on_header = harness.screen_cursor_position();
    let byte_on_header = harness.cursor_position();

    // Press Up — should move above the table (to the blank line before it)
    harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("│"))
        .unwrap();

    let pos_after_up = harness.screen_cursor_position();
    let byte_after_up = harness.cursor_position();

    // Cursor byte must have moved — it should NOT be stuck on the same byte
    assert_ne!(
        byte_after_up, byte_on_header,
        "Cursor byte position didn't change after pressing Up from the table header row. \
         Cursor is stuck! header_byte={}, after_up_byte={}",
        byte_on_header, byte_after_up,
    );

    // Cursor should be above the header row on screen
    assert!(
        pos_after_up.1 < pos_on_header.1,
        "Cursor screen row should decrease after pressing Up from header. \
         header_row={}, after_row={}",
        pos_on_header.1,
        pos_after_up.1,
    );

    // Press Up again — should reach "Some text above the table."
    harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("│"))
        .unwrap();

    let byte_on_text = harness.cursor_position();
    let content = harness.get_buffer_content().unwrap();
    let text_line_start = content.find("Some text above").unwrap();
    let text_line_end = content[text_line_start..]
        .find('\n')
        .map(|i| i + text_line_start)
        .unwrap_or(content.len());

    assert!(
        byte_on_text >= text_line_start && byte_on_text <= text_line_end,
        "After pressing Up twice from header, cursor should be on the 'Some text above' line. \
         cursor_byte={}, line_range=[{}, {}]",
        byte_on_text,
        text_line_start,
        text_line_end,
    );
}

/// Test that emphasis (bold, italic) and links render correctly inside table cells.
///
/// Prior to the fix, table rows had an early `return` that skipped emphasis/link
/// processing.  This test verifies that bold text inside a table cell gets the
/// bold overlay and that raw `**` markers are concealed.
#[test]
fn test_compose_mode_table_emphasis() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Table Emphasis

| Feature | Status |
|---|---|
| **Bold** item | *Done* |
| Normal item | [link](https://example.com) |

End of test.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_emphasis_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("table_emphasis_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate: table box-drawing conceals applied.
    // The table header should show │ delimiters once compose mode processes it.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            // Table conceals active AND emphasis conceals active (no raw **)
            s.lines().any(|l| l.contains("│") && l.contains("Bold"))
                && !s.lines().any(|l| l.contains("**"))
        })
        .unwrap();

    let screen = harness.screen_to_string();

    // Bold text should be visible (the word "Bold" without ** markers)
    assert!(
        screen.contains("Bold"),
        "Bold text should be visible in table cell.\nScreen:\n{}",
        screen,
    );

    // Link text should be visible. It may be wrapped in OSC 8 escape sequences
    // which split the text into 2-char chunks, so check for a partial match.
    assert!(
        screen.contains("li") && screen.contains("nk"),
        "Link text should be visible in table cell.\nScreen:\n{}",
        screen,
    );

    // Link markdown syntax [text](url) should be concealed — the square bracket
    // and parenthesis markup should not appear. Note: OSC 8 escape sequences
    // contain the URL as `]8;;url\x07` which we must not confuse with `](`.
    // Check for the actual markdown link pattern `](http`.
    let has_markdown_link_syntax = screen.lines().any(|l| {
        // Strip OSC 8 sequences to check for raw markdown syntax
        let stripped = l
            .replace(|c: char| c == '\x1B' || c == '\x07', "")
            .replace("]8;;", "");
        stripped.contains("](http") || stripped.contains("](https")
    });
    assert!(
        !has_markdown_link_syntax,
        "Markdown link syntax [text](url) should be concealed inside table cells.\nScreen:\n{}",
        screen,
    );
}

/// Test that links in compose mode produce OSC 8 hyperlink escape sequences.
///
/// When a markdown link `[text](url)` is rendered in compose mode, the overlay
/// for the link text should carry the URL.  The rendering pipeline should then
/// wrap the link text in OSC 8 sequences so that terminals supporting the
/// protocol show it as a clickable hyperlink.
#[test]
#[ignore] // TODO: OSC 8 hyperlink overlays disabled — ratatui Buffer::diff
          // skips cells due to inflated symbol width from OSC 8 escape sequences.
          // See the TODO in split_rendering.rs apply_hyperlink_overlays.
fn test_compose_mode_link_osc8() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Link Test

Here is a [click me](https://example.com) link.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("link_osc8_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("link_osc8_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate: link conceals applied.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            // "cl" is the first 2-char OSC 8 chunk of "click me"
            (s.contains("click me") || s.contains("cl"))
                && !s.lines().any(|l| {
                    // Check for raw markdown link syntax, ignoring OSC 8 escapes
                    let stripped = l.replace(|c: char| c == '\x1b' || c == '\x07', "");
                    let stripped = stripped.replace("]8;;", "");
                    stripped.contains("](http")
                })
        })
        .unwrap();

    // Check that the rendered buffer contains OSC 8 escape sequences with the URL.
    // The OSC 8 format is: \x1B]8;;<url>\x07<text>\x1B]8;;\x07
    let buf = harness.buffer();
    let has_osc8 = buf
        .content
        .iter()
        .any(|cell| cell.symbol().contains("\x1B]8;;https://example.com\x07"));
    let osc8_symbols: Vec<_> = buf
        .content
        .iter()
        .filter(|cell| cell.symbol().contains("\x1B]8"))
        .map(|cell| cell.symbol().to_string())
        .collect();
    assert!(
        has_osc8,
        "Rendered buffer should contain OSC 8 hyperlink sequences for https://example.com.\n\
         OSC 8 symbols found: {:?}",
        osc8_symbols,
    );
}

/// Test that the cursor remains visible at every column when moving right
/// through a line containing emphasis wrapping a link.
///
/// Regression test for a bug where the cursor disappears at certain positions
/// inside concealed emphasis + link spans (e.g. `**[Quick Install](#link)**`).
/// Two issues:
/// 1. OSC 8 hyperlink 2-char chunking can swallow the cursor cell
/// 2. Conceal boundary bytes missing from the view map cause cursor lookup to
///    fail (set_cursor_position never called → cursor vanishes)
///
/// The test moves Right through the entire line one character at a time and
/// asserts that the screen cursor x advances monotonically at every step
/// (no stuck positions indicating a missing set_cursor_position call).
#[test]
fn test_compose_mode_cursor_visibility_through_emphasis_link() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // A line with bold-wrapped link — the exact pattern that triggers the bug.
    let md_content = "\
# Test

**[Quick Install](#installation)** and more text here.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("cursor_emphasis_link.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("cursor_emphasis_link.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (emphasis concealed).
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("**"))
        .unwrap();

    // Navigate to start of the emphasis+link line
    // Lines: 0="# Test", 1=blank, 2="**[Quick Install](#installation)** and more…"
    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();

    // Wait for conceals to update after cursor movement.
    harness
        .wait_until_stable(|h| {
            // Cursor is on the emphasis line, so ** should be auto-exposed
            h.screen_to_string().contains("**")
        })
        .unwrap();

    // The raw line: **[Quick Install](#installation)** and more text here.
    // When cursor is on this line, auto-expose reveals all syntax markers.
    let line_len = "**[Quick Install](#installation)** and more text here.".len();

    let mut positions: Vec<(usize, u16, u16, usize)> = Vec::new(); // (step, x, y, byte)

    for step in 0..line_len {
        let (cx, cy) = harness.screen_cursor_position();
        let cursor_byte = harness.cursor_position();
        positions.push((step, cx, cy, cursor_byte));

        // Move right one position (send_key already renders)
        harness
            .send_key(KeyCode::Right, KeyModifiers::NONE)
            .unwrap();
        // Process async to let conceals update
        harness.process_async_and_render().unwrap();
    }

    // Verify: cursor byte must strictly advance at every step.
    // Screen x may jump backwards when conceals toggle (markers appear/disappear,
    // changing the visual length of preceding text), so we only check byte position.
    let mut failures: Vec<String> = Vec::new();
    for i in 1..positions.len() {
        let (step, cx, cy, byte) = positions[i];
        let (_, prev_cx, prev_cy, prev_byte) = positions[i - 1];

        if byte <= prev_byte {
            failures.push(format!(
                "Step {}: cursor byte did not advance. byte {} screen ({},{}) — prev byte {} screen ({},{})",
                step, byte, cx, cy, prev_byte, prev_cx, prev_cy,
            ));
        }
    }

    if !failures.is_empty() {
        // Dump all positions for debugging
        eprintln!("=== All cursor positions ===");
        for (step, cx, cy, byte) in &positions {
            eprintln!("  step {}: byte={} screen=({},{})", step, byte, cx, cy);
        }
    }

    assert!(
        failures.is_empty(),
        "Cursor did not advance at {} of {} steps:\n{}",
        failures.len(),
        line_len - 1,
        failures.join("\n"),
    );
}

/// Test that emphasis auto-expose is span-level: moving the cursor through
/// `*hello* *hello*` should always render the line as one of the valid forms
/// — never corrupted text. At every step, either the first span's markers
/// are exposed (and the second concealed), or vice versa, or both concealed.
#[test]
fn test_compose_mode_emphasis_auto_expose() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Test

*hello* *hello*
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("auto_expose_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("auto_expose_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (emphasis concealed).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("hello") && !s.contains("*hello*")
        })
        .unwrap();

    // Helper: find the rendered content of the hello line on screen.
    let get_hello_line = |screen: &str| -> String {
        screen
            .lines()
            .find(|l| l.contains("hello"))
            .unwrap_or("")
            .trim()
            .to_string()
    };

    // Navigate to the emphasis line and move right through every position.
    // Raw content: *hello* *hello*  (15 chars)
    // Line starts at byte 8 in the file (after "# Test\n\n").
    // Byte offsets within line:
    //   0=*  1=h  2=e  3=l  4=l  5=o  6=*  7=SPACE  8=*  9=h 10=e 11=l 12=l 13=o 14=*
    // First span: bytes 0..7 (*hello*)   → absolute bytes 8..15
    // Second span: bytes 8..15 (*hello*) → absolute bytes 16..23
    //
    // Expected: when cursor is in first span → "*hello* hello"
    //           when cursor is in gap       → "hello hello"
    //           when cursor is in second    → "hello *hello*"
    let line_byte_start: usize = 8; // "# Test\n\n" = 8 bytes

    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    // Wait for conceals to settle after cursor movement
    let mut prev_screen = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev_screen;
            prev_screen = s;
            stable
        })
        .unwrap();

    let raw_line = "*hello* *hello*";
    let mut failures: Vec<String> = Vec::new();

    for step in 0..=raw_line.len() {
        let screen = harness.screen_to_string();
        let rendered = get_hello_line(&screen);
        let cursor_byte = harness.cursor_position();
        let line_offset = cursor_byte.saturating_sub(line_byte_start);

        let (cx, cy) = harness.screen_cursor_position();
        eprintln!(
            "Step {:2}: byte={:2} line_off={:2} screen=({:2},{:2}) line='{}'",
            step, cursor_byte, line_offset, cx, cy, rendered,
        );

        // Determine which span the cursor is in and what the expected render is.
        // The auto-expose check uses inclusive bounds (c >= leadStart && c <= trailEnd),
        // so the byte just past the closing marker is still "in span".
        // First span:  *hello* = bytes 0..6, trailEnd=7 → exposed for offsets 0..7
        // Second span: *hello* = bytes 8..14, trailEnd=15 → exposed for offsets 8..15
        let expected = if line_offset <= 7 {
            // In or just past first *hello* span
            "*hello* hello"
        } else {
            // In or just past second *hello* span (or past end)
            "hello *hello*"
        };

        if rendered != expected {
            failures.push(format!(
                "Step {}: line_offset={} expected '{}' but got '{}'",
                step, line_offset, expected, rendered,
            ));
        }

        // Move right (skip on last iteration)
        if step < raw_line.len() {
            harness
                .send_key(KeyCode::Right, KeyModifiers::NONE)
                .unwrap();
            // Wait for conceals to settle
            let mut prev = String::new();
            harness
                .wait_until_stable(|h| {
                    let s = h.screen_to_string();
                    let stable = s == prev;
                    prev = s;
                    stable
                })
                .unwrap();
        }
    }

    if !failures.is_empty() {
        eprintln!("=== Failures ===");
        for f in &failures {
            eprintln!("{}", f);
        }
    }

    assert!(
        failures.is_empty(),
        "Line rendered incorrectly at {} of {} steps:\n{}",
        failures.len(),
        raw_line.len() + 1,
        failures.join("\n"),
    );
}

/// Test rendering and auto-expose of a markdown link in compose mode.
///
/// With cursor off the line, `[Quick Install](#installation)` should render
/// as just `Quick Install` (link syntax concealed). With cursor inside the
/// link span, the full raw markdown should be exposed.
#[test]
fn test_compose_mode_link_auto_expose() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Test

[Quick Install](#installation)
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("link_expose_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 40, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("link_expose_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate (link syntax concealed).
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("](#"))
        .unwrap();

    let link_row: u16 = 4; // row 0=menu, 1=tabs, 2="# Test", 3=blank, 4=link line

    // ── 1. Cursor on heading — link should be fully concealed ────────────
    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness
        .wait_until_stable(|h| !h.screen_to_string().contains("](#"))
        .unwrap();

    {
        let off_row = harness.screen_row_text(link_row);
        assert!(
            !off_row.contains("](#"),
            "With cursor off the link line, syntax should be concealed. Got: '{}'",
            off_row,
        );
    }

    // ── 2. Move to the link line and step through every position ─────────
    harness
        .send_key_repeat(KeyCode::Down, KeyModifiers::NONE, 2)
        .unwrap();
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    // Wait for link syntax to be exposed (cursor is now on the link line).
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("](#"))
        .unwrap();

    let raw_line = "[Quick Install](#installation)";
    let line_byte_start: usize = 8;
    let mut failures: Vec<String> = Vec::new();

    for step in 0..=raw_line.len() {
        harness.render().unwrap();
        let rendered = harness.screen_row_text(link_row);
        let exposed = rendered.contains("](#");
        let cursor_byte = harness.cursor_position();
        let line_offset = cursor_byte.saturating_sub(line_byte_start);
        // The link span covers the entire line (offsets 0..30 inclusive).
        // When cursor is anywhere on or just past the line, it should be exposed.
        let expect_exposed = line_offset <= raw_line.len();

        if exposed != expect_exposed {
            failures.push(format!(
                "Step {}: line_offset={} expected exposed={} but got exposed={}  line='{}'",
                step, line_offset, expect_exposed, exposed, rendered,
            ));
        }

        if step < raw_line.len() {
            harness
                .send_key(KeyCode::Right, KeyModifiers::NONE)
                .unwrap();
            harness.process_async_and_render().unwrap();
        }
    }

    assert!(
        failures.is_empty(),
        "Link expose/conceal incorrect at {} of {} steps:\n{}",
        failures.len(),
        raw_line.len() + 1,
        failures.join("\n"),
    );
}

/// Test that table rows with cells wider than the allocated column width
/// wrap onto extra visual lines rather than being truncated.
///
/// Verifies:
/// 1. Each visual line of a wrapped table row has exactly the expected
///    number of column separator characters (│) — no doubled `││`.
/// 2. Every visual line of a wrapped row starts with a leading `│`.
/// 3. The separator row (├─┼─┤) remains a single visual line.
#[test]
fn test_compose_mode_table_cell_wrapping() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Use a narrow viewport (60 cols) to force wrapping on a table
    // with a wide second column.
    let md_content = "\
# Table Wrap Test

| Category | Features |
|---|---|
| Editing | undo/redo, multi-cursor, block selection, smart indent |
| Language Server (LSP) | go to definition, references, hover, code actions, rename, diagnostics, autocompletion |
| Productivity | command palette, menu bar, keyboard macros, git log, diagnostics panel |

End.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_wrap_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Use a narrow width (60 cols) to force column compression and wrapping.
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(60, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("table_wrap_test.md");

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate with table conceals.
    // First wait for box-drawing to appear, then wait for screen stability
    // so all data rows have their conceals applied too.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();
    let screen = harness.screen_to_string();

    // Dump rendered output for debugging
    eprintln!("=== Rendered table wrapping output ===");
    for (i, line) in screen.lines().enumerate() {
        eprintln!("{:3}: {}", i, line);
    }
    eprintln!("=== End rendered output ===");

    // Collect all visual lines that contain box-drawing column separators.
    // This includes data rows, wrapped continuation lines, and separator rows.
    let table_lines: Vec<(usize, &str)> = screen
        .lines()
        .enumerate()
        .filter(|(_, l)| {
            let t = l.trim();
            t.contains('│') || t.contains('┼') || t.contains('├')
        })
        .collect();

    assert!(
        table_lines.len() >= 5,
        "Expected at least 5 visual table lines (header + separator + data rows with wrapping). \
         Found {}.\nScreen:\n{}",
        table_lines.len(),
        screen,
    );

    // For a 2-column table, each visual line should have exactly 3 column
    // separator characters: leading │, middle │, trailing │.
    // (Separator rows use ├/┼/┤ instead, also exactly 3.)
    let pipe_chars = ['│', '┼', '├', '┤'];
    let mut failures = Vec::new();

    for (line_num, line_text) in &table_lines {
        let pipe_count: usize = line_text.chars().filter(|c| pipe_chars.contains(c)).count();

        if pipe_count != 3 {
            failures.push(format!(
                "  line {}: {} separators (expected 3): {:?}",
                line_num,
                pipe_count,
                line_text.trim()
            ));
        }

        // Every table visual line must start with a box-drawing character
        // (after trimming leading whitespace for centered layout).
        let trimmed = line_text.trim_start();
        let first_char = trimmed.chars().next().unwrap_or(' ');
        if !pipe_chars.contains(&first_char) {
            failures.push(format!(
                "  line {}: missing leading separator (starts with {:?}): {:?}",
                line_num,
                first_char,
                line_text.trim()
            ));
        }
    }

    assert!(
        failures.is_empty(),
        "Table wrapping produced incorrect column separators:\n{}\n\nFull table lines:\n{}",
        failures.join("\n"),
        table_lines
            .iter()
            .map(|(n, l)| format!("  {:3}: {}", n, l))
            .collect::<Vec<_>>()
            .join("\n"),
    );
}

/// Regression test: pressing Down arrow past the end of a table should
/// advance the cursor to the next line below the table, NOT jump it
/// back to the beginning of the document.
#[test]
fn test_compose_mode_table_cursor_down_past_end() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Use a table with long cells that force wrapping in a narrow viewport
    let md_content = "\
# Cursor Test

Some intro text.

| Category | Features |
|---|---|
| Editing | undo/redo, multi-cursor, block selection, smart indent |
| Language Server (LSP) | go to definition, references, hover, code actions, rename, diagnostics, autocompletion |
| Productivity | command palette, menu bar, keyboard macros, git log, diagnostics panel |

After the table.

End of document.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("cursor_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Narrow viewport to force table cell wrapping
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(60, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for table conceals with wrapping.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();

    let content = harness.get_buffer_content().unwrap();
    let table_start_byte = content.find("| Category").unwrap();
    let after_table_byte = content.find("After the table").unwrap();

    // Cursor starts at byte 0 (top of file after opening).
    // Press Down repeatedly through heading, intro, table (with wrapped rows), and past end.
    // The table has wrapped rows creating extra visual lines.
    // We press Down enough times to get well past the table.
    for i in 0..18 {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();

        let pos = harness.cursor_position();
        let screen_pos = harness.screen_cursor_position();
        eprintln!(
            "Down press {}: cursor_byte={}, screen=({},{})",
            i + 1,
            pos,
            screen_pos.0,
            screen_pos.1
        );
    }

    let final_cursor = harness.cursor_position();
    let final_screen = harness.screen_cursor_position();

    eprintln!(
        "Final cursor: byte={}, screen=({},{})",
        final_cursor, final_screen.0, final_screen.1
    );

    // The cursor should NOT have jumped back to the beginning (byte 0)
    assert!(
        final_cursor > table_start_byte,
        "Cursor jumped back to byte {} which is before the table start at {}! \
         Pressing Down past a table should not jump to the beginning of the document.",
        final_cursor,
        table_start_byte,
    );

    // The cursor should be at or past the "After the table" line
    assert!(
        final_cursor >= after_table_byte,
        "After pressing Down through the table, cursor (byte {}) should be at or past \
         'After the table' (byte {}). Instead it's still inside the table.",
        final_cursor,
        after_table_byte,
    );
}

/// Regression test for the Down-arrow / table-borders interaction:
///
/// Place the cursor on a line *before* the table, press Down repeatedly,
/// and assert that each press advances by exactly one source line until
/// the cursor reaches the line *after* the table.
///
/// The original bug (added together with the table-border virtual lines
/// in markdown_compose) was that Down would land on a virtual border row
/// (`┌─┬─┐`, `├─┼─┤`, `└─┴─┘`), which has no source mapping, and the
/// cursor would either get stuck or rewind to the previous row's
/// `line_end_byte`.  The fix in `move_visual_line` walks past purely-
/// virtual rows; this test guards against regressions of that fix or the
/// table-border feature itself.
#[test]
fn test_compose_mode_cursor_down_through_table_borders() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Small, deterministic fixture.  Lines are numbered for clarity:
    //
    //   1: # Before
    //   2:
    //   3: Line before table.
    //   4:
    //   5: | A | B | C |
    //   6: |---|---|---|
    //   7: | 1 | 2 | 3 |
    //   8: | 4 | 5 | 6 |
    //   9: | 7 | 8 | 9 |
    //  10:
    //  11: Line after table.
    //
    // Compose mode renders the table with the markdown_compose
    // table-border feature, injecting `┌─┬─┐` above row 5, `├─┼─┤`
    // between rows 7/8 and 8/9, and `└─┴─┘` below row 9.  These are
    // virtual rows (no source mapping) — Down must skip past them so the
    // cursor advances exactly one source line per press.
    let md_content = "\
# Before

Line before table.

| A | B | C |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |
| 7 | 8 | 9 |

Line after table.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("cursor_through_table.md");
    std::fs::write(&md_path, md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 30, Default::default(), project_root)
            .unwrap();
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode.
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for the table-border virtual lines to be injected.
    // Top-border row contains `┌` and `┐`; bottom-border row contains `└`
    // and `┘`.  Stable means both are present at the same time.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("┌") && s.contains("┐") && s.contains("└") && s.contains("┘")
        })
        .unwrap();

    // Navigate cursor to source line 3 ("Line before table.") — two Down
    // presses from the heading on line 1.
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
    harness.render().unwrap();

    // Helper: read the buffer line number (0-indexed) the primary cursor
    // is currently on.
    let cursor_line = |h: &mut EditorTestHarness| -> usize {
        let pos = h.cursor_position();
        let content = h.get_buffer_content().unwrap();
        content[..pos.min(content.len())]
            .bytes()
            .filter(|&b| b == b'\n')
            .count()
    };

    // Sanity: cursor is on source line 2 (0-indexed) == line 3 in 1-indexed.
    assert_eq!(
        cursor_line(&mut harness),
        2,
        "Setup: cursor should start on 'Line before table.' (0-indexed line 2)"
    );

    // Press Down repeatedly through the table and into the after-table
    // text, recording the cursor's source-line index after each press.
    // Expected progression — one source line per press, no stalls, no
    // skips, no rewinds, and crossing the table boundaries:
    //
    //   start: 2  (Line before table.)
    //   after Down 1: 3  (blank)
    //   after Down 2: 4  (header `| A | B | C |`)
    //   after Down 3: 5  (source separator `|---|---|---|`)
    //   after Down 4: 6  (`| 1 | 2 | 3 |`)
    //   after Down 5: 7  (`| 4 | 5 | 6 |`)
    //   after Down 6: 8  (`| 7 | 8 | 9 |`)
    //   after Down 7: 9  (blank below table)
    //   after Down 8: 10 (`Line after table.`)
    let mut observed: Vec<usize> = vec![cursor_line(&mut harness)];
    for _ in 0..8 {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
        observed.push(cursor_line(&mut harness));
    }

    let expected: Vec<usize> = (2..=10).collect();
    assert_eq!(
        observed, expected,
        "Down-arrow progression through compose-mode table should advance \
         exactly one source line per press, skipping virtual border rows. \
         Observed line indices: {observed:?}, expected: {expected:?}"
    );

    // Final assertion: cursor must end on "Line after table.", not stuck
    // on a virtual border row or rewound to an earlier table row.
    let final_pos = harness.cursor_position();
    let content = harness.get_buffer_content().unwrap();
    let after_byte = content.find("Line after table.").unwrap();
    assert!(
        final_pos >= after_byte,
        "After {} Down presses cursor should be at or past 'Line after table.' \
         (byte {after_byte}), but is at byte {final_pos}",
        observed.len() - 1,
    );
}

/// Regression test for the table-border cleanup bug: toggling compose mode
/// OFF must remove the injected table-border virtual lines (`┌─┬─┐`,
/// `├─┼─┤`, `└─┴─┘`).
///
/// The borders live in per-row virtual-text namespaces (`md-tb-${line}`),
/// which are NOT covered by the bulk conceal/overlay/soft-break clears in
/// `disableMarkdownCompose`.  The original bug left them on screen as
/// orphaned virtual lines after compose mode was switched off.
#[test]
fn test_compose_mode_disable_removes_table_borders() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Table

| A | B | C |
|---|---|---|
| 1 | 2 | 3 |
| 4 | 5 | 6 |

Trailing text.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("disable_clears_borders.md");
    std::fs::write(&md_path, md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 30, Default::default(), project_root)
            .unwrap();
    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Helper: toggle compose mode via the command palette.
    let toggle_compose = |h: &mut EditorTestHarness| {
        h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
            .unwrap();
        h.wait_for_prompt().unwrap();
        h.type_text("Toggle Compose").unwrap();
        h.wait_for_screen_contains("Toggle Compose").unwrap();
        h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
        h.wait_for_prompt_closed().unwrap();
    };

    // Enable compose mode and wait for the border virtual lines to appear.
    toggle_compose(&mut harness);
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains('┌') && s.contains('┐') && s.contains('└') && s.contains('┘')
        })
        .unwrap();

    // Disable compose mode — the border glyphs must disappear.
    toggle_compose(&mut harness);
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            !s.contains('┌')
                && !s.contains('┐')
                && !s.contains('└')
                && !s.contains('┘')
                && !s.contains('┬')
                && !s.contains('┴')
                && !s.contains('├')
                && !s.contains('┤')
        })
        .unwrap();

    // Sanity: the raw markdown source is back (pipes visible again).
    let screen = harness.screen_to_string();
    assert!(
        screen.contains("| A | B | C |"),
        "After disabling compose mode the raw table source should be visible, \
         but the screen was:\n{screen}"
    );
}

/// Regression test: pressing Down through a document with tables (in compose mode)
/// and then Up all the way back must produce monotonically increasing then
/// monotonically decreasing cursor byte positions.  A jump to byte 0 mid-sequence
/// is the specific bug being guarded against.
#[test]
fn test_compose_mode_cursor_monotonic_through_tables() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crossterm::event::{KeyCode, KeyModifiers};

    // Synthetic document with the features that trigger the bug: tables with
    // long cells, bold/italic, links, and code blocks — but small enough to
    // traverse quickly.
    let md_content = "\
# Monotonic Cursor Test

Some **bold text** and *italic text* here.

| Feature | Description | Status |
|---------|-------------|--------|
| **Tables** | Box-drawing conceals | Done |
| *Links* | [Click here](https://example.com) | Done |
| Code | `inline code` rendering | Done |

Middle paragraph with [a link](https://example.com) and **emphasis**.

| Name | Value | Notes |
|------|-------|-------|
| Alpha | 100 | First row |
| Beta | 200 | Second row |
| Gamma | 300 | Third row |

```rust
fn example() {
    println!(\"hello\");
}
```

Final paragraph with **more bold** and *more italic*.

End.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("monotonic_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(60, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode conceals to activate.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") || s.contains("─") || !s.contains("**")
        })
        .unwrap();

    // --- Move Down through entire document ---
    let total_downs = 60;
    let mut prev_pos = harness.cursor_position();
    let mut down_positions = vec![prev_pos];

    for i in 0..total_downs {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();

        let pos = harness.cursor_position();
        assert!(
            pos >= prev_pos,
            "Down press {}: cursor went backwards! {} -> {} (positions so far: {:?})",
            i + 1,
            prev_pos,
            pos,
            down_positions,
        );
        down_positions.push(pos);
        prev_pos = pos;
    }

    // --- Move Up through entire document ---
    let total_ups = 60;
    prev_pos = harness.cursor_position();
    let mut up_positions = vec![prev_pos];

    for i in 0..total_ups {
        harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();

        let pos = harness.cursor_position();
        assert!(
            pos <= prev_pos,
            "Up press {}: cursor went forwards! {} -> {} (positions so far: {:?})",
            i + 1,
            prev_pos,
            pos,
            up_positions,
        );
        up_positions.push(pos);
        prev_pos = pos;
    }
}

/// Regression test: cursor on a numbered list item should maintain its visual
/// column when moving up.  When on the first character of "2.", pressing Up
/// should land near the start of the line above (visual column 0), not jump
/// to the end of that line.
#[test]
fn test_compose_mode_cursor_column_sticky_on_list() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crossterm::event::{KeyCode, KeyModifiers};

    let md_content = "\
# List Test

1. **Reproduce Before Fixing**: Always include a test case that reproduces the bug (fails) without the fix, and passes with the fix. This ensures the issue is verified and prevents future regressions.

2. **E2E Tests for New Flows**: Any new user flow or feature must include an end-to-end (e2e) test. E2E tests send keyboard/mouse events and examines the final rendered output, do not examine internal state.

3. **No timeouts or time-sensitive tests**: Use semantic waiting instead of fixed timers to ensure test stability.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("list_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Narrow viewport to force wrapping of the long list items
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(40, 20, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to settle — emphasis markers should be concealed
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("Reproduce") && !s.contains("**")
        })
        .unwrap();

    // Navigate to the start of "2." — use Home to go to line start first,
    // then Down to reach it
    let content = harness.get_buffer_content().unwrap();
    let item2_byte = content.find("2. **E2E").unwrap();

    // Move cursor to the "2." line by pressing Down until we're there
    for _ in 0..20 {
        let pos = harness.cursor_position();
        if pos >= item2_byte {
            break;
        }
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
    }

    // Press Home to go to column 0 of the "2." line
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    harness.render().unwrap();

    let pos_on_item2 = harness.cursor_position();
    let screen_before = harness.screen_cursor_position();
    let visual_col_before = screen_before.0;

    // Now press Up — cursor should land near the same visual column on the
    // line above, NOT at the end of the line above
    harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
    harness.render().unwrap();

    let pos_after_up = harness.cursor_position();
    let screen_after = harness.screen_cursor_position();
    let visual_col_after = screen_after.0;

    // The visual column should stay near column 0 (within a small tolerance
    // for gutter width differences), definitely not jump to the end
    let col_distance = if visual_col_after > visual_col_before {
        visual_col_after - visual_col_before
    } else {
        visual_col_before - visual_col_after
    };

    assert!(
        col_distance <= 3,
        "Pressing Up from start of '2.' line (visual col {}) should land near \
         the same column on the line above, but jumped to visual col {} \
         (distance {}). cursor byte: {} -> {}",
        visual_col_before,
        visual_col_after,
        col_distance,
        pos_on_item2,
        pos_after_up,
    );
}

/// Regression test: visual column should remain sticky at column 0 when
/// navigating up and down through wrapped numbered list items.
/// Start at column 0, press Down through all items, then Up back to the top.
/// The visual column should stay at the gutter-adjusted column 0 throughout.
#[test]
fn test_compose_mode_cursor_column_zero_sticky_through_wrapped_list() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crossterm::event::{KeyCode, KeyModifiers};

    // Numbered list with items long enough to wrap at 50 columns
    let md_content = "\
# Wrapped List

1. First item with enough text to wrap across multiple visual lines in a narrow viewport.

2. Second item also has enough text that it will definitely wrap when rendered in compose mode.

3. Third item continues the pattern of long text that wraps across multiple lines in the viewport.

4. Fourth item is similarly long to ensure consistent wrapping behavior throughout the list.

5. Fifth and final item rounds out the list with more wrapping text for thorough coverage.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("wrapped_list.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Narrow viewport to force wrapping
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(50, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully settle — list content visible
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("First item"))
        .unwrap();

    // Press Home to ensure we start at column 0
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    harness.render().unwrap();

    let start_col = harness.screen_cursor_position().0;
    let gutter_col = start_col; // this is column 0 adjusted for gutter

    // --- Move Down through the entire list ---
    // On wrapped continuation lines, the cursor may land a few columns
    // from 0 due to indentation padding.  The important properties are:
    //  1. The cursor stays near column 0 (not jumping to the end)
    //  2. The round trip (down then up) returns to column 0
    let total_presses = 25; // enough to traverse all 5 items + wrapping
    let max_drift = 10; // allow for indentation on wrapped continuation lines

    for i in 0..total_presses {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        // Wait for screen to stabilize after the plugin chain
        // (cursor_moved → refreshLines → lines_changed → soft-breaks).
        let mut prev = String::new();
        harness
            .wait_until_stable(|h| {
                let s = h.screen_to_string();
                let stable = s == prev;
                prev = s;
                stable
            })
            .unwrap();

        let screen = harness.screen_cursor_position();
        let col = screen.0;
        assert!(
            col <= gutter_col + max_drift,
            "Down press {}: visual column {} drifted too far from start column {} \
             (screen row {})",
            i + 1,
            col,
            gutter_col,
            screen.1,
        );
    }

    // --- Move Up back to the top ---
    for i in 0..total_presses {
        harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
        let mut prev = String::new();
        harness
            .wait_until_stable(|h| {
                let s = h.screen_to_string();
                let stable = s == prev;
                prev = s;
                stable
            })
            .unwrap();

        let screen = harness.screen_cursor_position();
        let col = screen.0;
        assert!(
            col <= gutter_col + max_drift,
            "Up press {}: visual column {} drifted too far from start column {} \
             (screen row {})",
            i + 1,
            col,
            gutter_col,
            screen.1,
        );
    }

    // After the full round trip, cursor should be near column 0.
    // Note: sticky_column=0 is treated as "unset" in the cursor model,
    // so column 0 cannot be perfectly sticky.  Allow same tolerance.
    let final_col = harness.screen_cursor_position().0;
    assert!(
        final_col <= gutter_col + max_drift,
        "After full down+up round trip, cursor column {} drifted too far from start {}",
        final_col,
        gutter_col,
    );
}

/// Regression test: table rows with emphasis markup should not overflow when
/// cursor enters them.
///
/// When the cursor moves into a table row containing `**bold**`, the auto-expose
/// feature reveals the `**` markers. Without the fix, the cell width was computed
/// from the concealed text (shorter), so the revealed markers overflow the
/// allocated column width, causing the row to wrap onto a second visual line.
///
/// The fix sizes columns based on raw (unconcealed) text so revealed markers
/// always fit, and skips emphasis conceals for the entire row when the cursor
/// is on it.
#[test]
fn test_compose_mode_table_emphasis_auto_expose_no_overflow() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Table sized so that:
    //  - Concealed row fits in 60-col viewport (57 chars)
    //  - Revealed row (with **) overflows (61 chars) → wraps without fix
    //
    // Row: | **Search & Replace** | Find and replace text in the docs! |
    //  concealed cells: " Search & Replace " (18) + " Find and replace text in the docs! " (36)
    //  → total concealed row = 18 + 36 + 3 pipes = 57 chars
    //  → total revealed row  = 22 + 36 + 3 pipes = 61 chars
    let md_content = "\
# Test

| Feature | Description |
|---|---|
| **Search & Replace** | Find and replace text in the docs! |
| Normal item | Plain text cell |

Done.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_auto_expose.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Viewport 60 cols: concealed row (57) fits, revealed row (61) overflows
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(60, 20, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode table to be fully rendered with box-drawing
    // and emphasis concealed.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.lines().any(|l| l.contains("│") && l.contains("Search"))
                && !s.lines().any(|l| l.contains("**"))
        })
        .unwrap();

    // Count how many screen lines contain "Search" — should be exactly 1
    // (the row is not wrapping)
    let screen_before = harness.screen_to_string();
    let search_lines_before: Vec<_> = screen_before
        .lines()
        .filter(|l| l.contains("Search"))
        .collect();
    assert_eq!(
        search_lines_before.len(),
        1,
        "Before cursor: 'Search' row should be on exactly 1 screen line.\nScreen:\n{}",
        screen_before,
    );

    // Navigate cursor into the emphasis span on the "Search & Replace" row.
    // First go to the top, then down to the data row, then right into the span.
    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();

    // Move down: heading(1) → blank(2) → header(3) → separator(4) → data row(5)
    for _ in 0..4 {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
    }
    // Move right into the bold span (past the leading pipe + space + **)
    for _ in 0..4 {
        harness
            .send_key(KeyCode::Right, KeyModifiers::NONE)
            .unwrap();
        harness.render().unwrap();
    }

    // Wait for emphasis markers to be auto-exposed on the cursor row.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.lines().any(|l| l.contains("**Search"))
        })
        .unwrap();

    let screen_after = harness.screen_to_string();

    // The cursor row shows raw text without padding/truncation, so it may
    // overflow the viewport width (raw text is wider than allocated columns).
    // This is expected — cursor positioning requires the raw bytes to be
    // present (segment conceals break cursor mapping). Verify the raw text
    // and pipe replacements are visible.
    // TODO: Ideally the cursor row should fit within the viewport (with
    // proper padding/truncation) while still allowing correct cursor
    // positioning. This overflow is a workaround for the segment-conceal
    // cursor mapping bug.
    let search_line = screen_after
        .lines()
        .find(|l| l.contains("Search"))
        .expect("Should find line with 'Search'");
    assert!(
        search_line.contains("│"),
        "Cursor row should still have pipe replacements (│).\n\
         Got: '{}'\nScreen:\n{}",
        search_line,
        screen_after,
    );
}

/// Regression test: cursor rendering while navigating through table rows
/// with emphasis markup.
///
/// Uses a 6-row data table where every row has emphasis. Moves the cursor
/// down through every row, then back up, and at each position asserts that
/// ONLY the cursor's row has its emphasis markers auto-exposed while all
/// other rows keep them concealed.
#[test]
fn test_compose_mode_table_cursor_render_through_emphasis() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // 6 data rows, each with bold emphasis in the first column.
    // Row labels are unique so we can identify them on screen.
    let md_content = "\
# Table

| Feature | Status |
|---|---|
| **Alpha** | *Done* |
| **Bravo** | *Active* |
| **Charlie** | *Pending* |
| **Delta** | *Review* |
| **Echo** | *Blocked* |
| **Foxtrot** | *Shipped* |

End.
";

    // The row labels without emphasis markers (concealed form)
    let labels = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"];

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_cursor_render.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully activate: table box-drawing visible and
    // all emphasis concealed.
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.lines().any(|l| l.contains("│") && l.contains("Alpha"))
                && !s.lines().any(|l| l.contains("**"))
        })
        .unwrap();

    /// Assert that exactly the row for `exposed_label` (if Some) has visible
    /// `**` markers, and all other rows have them concealed.
    fn assert_only_row_exposed(
        screen: &str,
        labels: &[&str],
        exposed_label: Option<&str>,
        context: &str,
    ) {
        for &label in labels {
            let should_expose = exposed_label == Some(label);
            let marker = format!("**{}**", label);
            let has_marker = screen.lines().any(|l| l.contains(&marker));

            if should_expose {
                assert!(
                    has_marker,
                    "{}: cursor row '{}' should show '{}' but markers are concealed.\nScreen:\n{}",
                    context, label, marker, screen,
                );
            } else {
                // The label text itself should be present (concealed form)
                // but the ** markers should not surround it.
                assert!(
                    !has_marker,
                    "{}: non-cursor row '{}' should have ** concealed but found '{}'.\nScreen:\n{}",
                    context, label, marker, screen,
                );
            }
        }
    }

    /// Wait until the screen shows `**{label}**` for the expected row AND
    /// all other rows have their emphasis concealed. Waiting for the full
    /// stable state prevents races where the plugin is still re-processing
    /// other rows.
    /// Advances logical time to allow debounce/plugin processing to complete.
    fn wait_for_only_row_exposed(
        harness: &mut EditorTestHarness,
        labels: &[&str],
        exposed_label: &str,
    ) {
        let wait_sleep = std::time::Duration::from_millis(50);
        loop {
            harness.process_async_and_render().unwrap();
            let s = harness.screen_to_string();
            // Check: exposed_label has markers, all others do not
            let target_ok = s
                .lines()
                .any(|l| l.contains(&format!("**{}**", exposed_label)));
            let others_ok = labels.iter().all(|&l| {
                l == exposed_label || !s.lines().any(|line| line.contains(&format!("**{}**", l)))
            });
            if target_ok && others_ok {
                return;
            }
            std::thread::sleep(wait_sleep);
            harness.advance_time(wait_sleep);
        }
    }

    /// Wait until no rows in the table have exposed emphasis markers.
    /// Advances logical time to allow debounce/plugin processing to complete.
    fn wait_for_all_concealed(harness: &mut EditorTestHarness, labels: &[&str]) {
        let wait_sleep = std::time::Duration::from_millis(50);
        loop {
            harness.process_async_and_render().unwrap();
            let s = harness.screen_to_string();
            let any_exposed = labels
                .iter()
                .any(|l| s.lines().any(|line| line.contains(&format!("**{}**", l))));
            if !any_exposed {
                return;
            }
            std::thread::sleep(wait_sleep);
            harness.advance_time(wait_sleep);
        }
    }

    // Navigate: Ctrl+Home → Down×4 to reach first data row (Alpha)
    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    for _ in 0..4 {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
    }
    wait_for_only_row_exposed(&mut harness, &labels, labels[0]);

    // --- Move DOWN through all 6 rows ---
    for i in 0..labels.len() {
        let screen = harness.screen_to_string();
        assert_only_row_exposed(
            &screen,
            &labels,
            Some(labels[i]),
            &format!("Down pass, row {} ({})", i, labels[i]),
        );

        // Move down to next row (skip on the last iteration)
        if i + 1 < labels.len() {
            harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
            wait_for_only_row_exposed(&mut harness, &labels, labels[i + 1]);
        }
    }

    // --- Move UP back through all 6 rows ---
    for i in (0..labels.len()).rev() {
        let screen = harness.screen_to_string();
        assert_only_row_exposed(
            &screen,
            &labels,
            Some(labels[i]),
            &format!("Up pass, row {} ({})", i, labels[i]),
        );

        // Move up to previous row (skip on the last iteration)
        if i > 0 {
            harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
            wait_for_only_row_exposed(&mut harness, &labels, labels[i - 1]);
        }
    }

    // --- Move up past the table: all rows should be concealed ---
    // From Alpha row, move up past separator and header to above the table
    for _ in 0..3 {
        harness.send_key(KeyCode::Up, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
    }
    wait_for_all_concealed(&mut harness, &labels);

    let screen = harness.screen_to_string();
    assert_only_row_exposed(&screen, &labels, None, "Cursor above table");
}

/// Test cursor rendering while pressing Right arrow through an entire table
/// that has emphasis causing cell wrapping on some rows.
///
/// Uses a 5-row table with various text lengths — some rows have bold emphasis
/// wide enough to trigger multi-line cell wrapping when revealed. Presses
/// Right through every source byte and asserts that:
/// 1. Exactly one cursor is visible on screen at each step
/// 2. The screen cursor position (row, col) is lexicographically non-decreasing
///    — col increases within a visual line, row increases at wraps/newlines
#[test]
fn test_compose_mode_table_cursor_right_through_emphasis_wrap() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};
    use std::time::Duration;

    init_tracing_from_env();

    // Table modeled after the real README — emphasis in the first column
    // that is long enough to cause multi-line cell wrapping when the **
    // markers are revealed. The "Language Server Protocol (LSP)" cell is
    // the key trigger row.
    let md_content = "\
# Test

| Feature | Description |
|---|---|
| File Management | open, save, close, tabs |
| Editing | undo, redo, multi-cursor |
| **Language Server Protocol (LSP)** | go to definition, hover, rename |
| Productivity | command palette, macros |
| **Bold** | Short |

Done.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_right_wrap.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Narrow viewport to force the LSP row to wrap when emphasis revealed.
    // Raw cell: "**Language Server Protocol (LSP)**" = 34 chars
    // At viewport 55, allocated column width will be smaller → cell wraps.
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(55, 25, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to fully settle — table with box-drawing visible
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.lines().any(|l| l.contains("│") && l.contains("Editing"))
        })
        .unwrap();

    // Navigate to start of first data row:
    // Ctrl+Home, Down×4 (heading, blank, header, separator)
    harness
        .send_key(KeyCode::Home, KeyModifiers::CONTROL)
        .unwrap();
    harness.render().unwrap();
    for _ in 0..4 {
        harness.send_key(KeyCode::Down, KeyModifiers::NONE).unwrap();
        harness.render().unwrap();
    }
    harness.send_key(KeyCode::Home, KeyModifiers::NONE).unwrap();
    for _ in 0..3 {
        harness.process_async_and_render().unwrap();
        std::thread::sleep(Duration::from_millis(50));
        harness.advance_time(Duration::from_millis(50));
    }

    // Find byte range of all 5 data rows to know when we've passed the table.
    let content = harness.get_buffer_content().unwrap();
    let table_end_marker = "| **Bold** | Short |";
    let last_row_start = content.find(table_end_marker).unwrap();
    let table_end = last_row_start + table_end_marker.len() + 1; // +1 for newline

    let mut prev_byte: usize = 0;
    let mut prev_row: i32 = -1;
    let mut prev_col: i32 = -1;
    let mut stuck_count: usize = 0; // consecutive steps with same screen position
    let mut failures: Vec<String> = Vec::new();
    let mut step = 0;

    // Press Right through the entire table, checking cursor at each step.
    // Max steps prevents infinite loop if cursor gets stuck.
    let max_steps = 500;
    loop {
        // Process async to let conceals update after cursor move
        harness.process_async_and_render().unwrap();
        std::thread::sleep(Duration::from_millis(30));
        harness.advance_time(Duration::from_millis(30));
        harness.process_async_and_render().unwrap();

        let byte = harness.cursor_position();
        if byte >= table_end || step >= max_steps {
            break; // Cursor has left the table or max steps reached
        }

        let (col, row) = harness.screen_cursor_position();
        let cursors = harness.find_all_cursors();

        // Check: exactly 1 cursor visible on screen
        if cursors.len() != 1 {
            failures.push(format!(
                "Step {}: expected 1 cursor, found {} at byte {} (screen {},{}).\n  Cursors: {:?}",
                step,
                cursors.len(),
                byte,
                col,
                row,
                cursors,
            ));
        }

        // Check: cursor byte position monotonically non-decreasing.
        if step > 0 && byte < prev_byte {
            failures.push(format!(
                "Step {}: cursor byte went backward {} → {} (screen {},{} → {},{})",
                step, prev_byte, byte, prev_col, prev_row, col, row,
            ));
        }

        // Check: within the same screen row, column must not decrease,
        // UNLESS it resets to column 0 which indicates a line wrap (the raw
        // text is wider than the viewport when emphasis markers are revealed).
        if prev_row >= 0 && (row as i32) == prev_row && (col as i32) < prev_col && col != 0 {
            failures.push(format!(
                "Step {}: column went backward on same row ({},{}) → ({},{}) at byte {}",
                step, prev_col, prev_row, col, row, byte,
            ));
        }

        // Check: cursor screen position must actually advance — it should
        // not stay stuck at the same (row, col) for many consecutive steps.
        // Conceals can map a few adjacent source bytes to the same visual
        // position, but more than 3 consecutive stuck steps means the
        // cursor is not rendering in the correct position.
        if (row as i32) == prev_row && (col as i32) == prev_col {
            stuck_count += 1;
            if stuck_count > 3 {
                failures.push(format!(
                    "Step {}: cursor stuck at ({},{}) for {} consecutive steps \
                     (byte {} → {}). Cursor not rendering correctly.",
                    step,
                    col,
                    row,
                    stuck_count + 1,
                    prev_byte,
                    byte,
                ));
            }
        } else {
            stuck_count = 0;
        }

        prev_byte = byte;
        prev_row = row as i32;
        prev_col = col as i32;
        step += 1;

        // Move right
        harness
            .send_key(KeyCode::Right, KeyModifiers::NONE)
            .unwrap();
        harness.render().unwrap();
    }

    assert!(
        step > 50,
        "Expected to traverse >50 steps through the table, but only got {}",
        step,
    );

    if !failures.is_empty() {
        eprintln!("=== {} failures out of {} steps ===", failures.len(), step);
        for f in &failures {
            eprintln!("{}", f);
        }
    }
    assert!(
        failures.is_empty(),
        "Cursor position errors at {} of {} steps:\n{}",
        failures.len(),
        step,
        failures.join("\n"),
    );
}

/// Test that mouse clicks position the cursor at the correct visual column
/// when compose width is narrower than the window width.
///
/// With composeWidth < viewport, content is centered with left padding.
/// A mouse click on a visual column should place the cursor at the
/// corresponding buffer position, not at an offset position.
#[test]
fn test_compose_mode_mouse_click_with_narrow_compose_width() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};
    use std::time::Duration;

    init_tracing_from_env();

    let md_content = "\
# Hello World

This is a test paragraph with some text.

Another line here.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("mouse_click.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Wide viewport (80) so we can set a narrower compose width
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 25, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Set compose width to 50 (narrower than 80-col viewport)
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Set Compose Width").unwrap();
    harness
        .wait_for_screen_contains("Set Compose Width")
        .unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("50").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to settle with new width — text should be visible
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("This is a test"))
        .unwrap();

    // The text line "This is a test paragraph with some text." should be
    // visible and centered. Find its screen row.
    let screen = harness.screen_to_string();
    let target_text = "This is a test";
    let target_row = screen
        .lines()
        .enumerate()
        .find(|(_, l)| l.contains(target_text))
        .map(|(i, _)| i as u16)
        .expect(&format!(
            "Should find '{}' on screen.\nScreen:\n{}",
            target_text, screen,
        ));

    // Find the column offset where "This" starts on screen
    let target_line = screen.lines().nth(target_row as usize).unwrap();
    let text_col_start = target_line.find("This").unwrap() as u16;

    // Click at various offsets within the text and verify cursor column
    let test_offsets: Vec<u16> = vec![0, 5, 10, 20, 30];

    for &offset in &test_offsets {
        let click_col = text_col_start + offset;
        harness.mouse_click(click_col, target_row).unwrap();

        for _ in 0..3 {
            harness.process_async_and_render().unwrap();
            std::thread::sleep(Duration::from_millis(30));
            harness.advance_time(Duration::from_millis(30));
        }

        let (cursor_col, cursor_row) = harness.screen_cursor_position();

        assert_eq!(
            cursor_row,
            target_row,
            "After clicking at column {} on row {}, cursor should be on the same row. \
             Got row {}.\nScreen:\n{}",
            click_col,
            target_row,
            cursor_row,
            harness.screen_to_string(),
        );

        assert_eq!(
            cursor_col,
            click_col,
            "After clicking at column {} on row {}, cursor should be at the same column. \
             Got column {}.\nScreen:\n{}",
            click_col,
            target_row,
            cursor_col,
            harness.screen_to_string(),
        );
    }
}

/// Test that mouse-clicking on emphasis text auto-exposes the markers,
/// the same way keyboard navigation does.
///
/// In compose mode, emphasis markers (`**`) are concealed. When the cursor
/// moves into an emphasis span via keyboard, the markers are revealed.
/// Mouse clicks should trigger the same behavior.
#[test]
fn test_compose_mode_mouse_click_auto_exposes_emphasis() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Title

Some **bold text** here.

End.
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("mouse_expose.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 20, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();
    eprintln!(
        "[DIAG] after open+render, screen:\n{}",
        harness.screen_to_string()
    );

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();
    eprintln!(
        "[DIAG] after Toggle Compose, screen:\n{}",
        harness.screen_to_string()
    );

    // Wait for compose mode to activate: emphasis should be concealed.
    let init_iter = std::sync::atomic::AtomicUsize::new(0);
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let i = init_iter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            let has_bold = s.contains("bold text");
            let has_stars = s.contains("**");
            eprintln!("[DIAG] init wait iter={i} has_bold={has_bold} has_stars={has_stars}");
            eprintln!("[DIAG] screen:\n{s}");
            has_bold && !has_stars
        })
        .unwrap();
    eprintln!(
        "[DIAG] compose init done after {} iters",
        init_iter.load(std::sync::atomic::Ordering::Relaxed)
    );

    // Find the screen position of "bold" to click on it
    let screen_before = harness.screen_to_string();
    let bold_row = screen_before
        .lines()
        .enumerate()
        .find(|(_, l)| l.contains("bold text"))
        .map(|(i, _)| i as u16)
        .expect("Should find 'bold text' on screen");
    let bold_line = screen_before.lines().nth(bold_row as usize).unwrap();
    let bold_col = bold_line.find("bold").unwrap() as u16;
    eprintln!("[DIAG] clicking at col={}, row={}", bold_col + 2, bold_row);

    // Click on "bold text"
    harness.mouse_click(bold_col + 2, bold_row).unwrap();
    eprintln!(
        "[DIAG] after mouse_click, screen:\n{}",
        harness.screen_to_string()
    );

    // Wait for emphasis markers to be auto-exposed by mouse click.
    let expose_iter = std::sync::atomic::AtomicUsize::new(0);
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let i = expose_iter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            let has_exposed = s.contains("**bold text**");
            let has_stars = s.contains("**");
            let has_bold = s.contains("bold text");
            if i < 5 || i % 50 == 0 {
                eprintln!("[DIAG] expose wait iter={i} has_exposed={has_exposed} has_stars={has_stars} has_bold={has_bold}");
                if i % 50 == 0 {
                    eprintln!("[DIAG] screen:\n{s}");
                }
            }
            has_exposed
        })
        .unwrap();
    eprintln!(
        "[DIAG] expose done after {} iters",
        expose_iter.load(std::sync::atomic::Ordering::Relaxed)
    );

    let screen_after = harness.screen_to_string();

    // The emphasis markers should now be visible (auto-exposed by mouse click)
    assert!(
        screen_after.contains("**bold text**"),
        "After clicking on emphasis text, markers should be auto-exposed.\nScreen:\n{}",
        screen_after,
    );
}

/// Test setting compose width via command palette, verifying that a long line
/// wraps at different lengths when the width is changed.
#[test]
fn test_compose_mode_set_width_via_command_palette() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};
    use std::time::Duration;

    init_tracing_from_env();

    // 200 x's — long enough to wrap at any compose width we test
    let long_line = "x".repeat(200);
    let md_content = format!("# Width Test\n\n{}\n", long_line);

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("width_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    let mut harness =
        EditorTestHarness::with_config_and_working_dir(80, 25, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Helper: collect lengths of all x-only lines
    let x_line_lengths = |screen: &str| -> Vec<usize> {
        screen
            .lines()
            .filter(|l| {
                let trimmed = l.trim();
                !trimmed.is_empty() && trimmed.chars().all(|c| c == 'x')
            })
            .map(|l| l.trim().len())
            .collect::<Vec<_>>()
    };

    // Helper: open command palette and run a command by name
    let run_command = |h: &mut EditorTestHarness, name: &str| {
        h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
            .unwrap();
        h.wait_for_prompt().unwrap();
        h.type_text(name).unwrap();
        h.wait_for_screen_contains(name).unwrap();
        h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
    };

    // Helper: let compose mode settle after a change
    let settle = |h: &mut EditorTestHarness| {
        for _ in 0..10 {
            h.process_async_and_render().unwrap();
            std::thread::sleep(Duration::from_millis(50));
            h.advance_time(Duration::from_millis(50));
        }
        h.render().unwrap();
    };

    // Enable compose mode
    run_command(&mut harness, "Toggle Compose");
    harness.wait_for_prompt_closed().unwrap();
    settle(&mut harness);

    let screen_default = harness.screen_to_string();
    let default_lengths = x_line_lengths(&screen_default);
    println!("=== DEFAULT (no compose width set) ===");
    println!("x-line lengths: {:?}", default_lengths);
    println!("{}", screen_default);

    // --- Set compose width to 40 by typing ---
    run_command(&mut harness, "Set Compose Width");
    harness.wait_for_prompt().unwrap();
    harness.type_text("40").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();
    settle(&mut harness);

    let screen_40 = harness.screen_to_string();
    let w40_lengths = x_line_lengths(&screen_40);
    println!("=== AFTER SET WIDTH TO 40 ===");
    println!("x-line lengths: {:?}", w40_lengths);
    println!("{}", screen_40);

    // One content column is reserved at wrap time so the EOL cursor can't
    // overlap the vertical scrollbar, so "width 40" renders 39 content cols.
    assert_eq!(
        w40_lengths[0], 39,
        "With compose width 40, first x-line should be 39 chars (1 col reserved for cursor), got {:?}",
        w40_lengths,
    );

    // --- Open prompt again and just press Enter (should keep width=40) ---
    run_command(&mut harness, "Set Compose Width");
    harness.wait_for_prompt().unwrap();
    // Don't change anything, just confirm the pre-filled value
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();
    settle(&mut harness);

    let screen_reconfirm = harness.screen_to_string();
    let reconfirm_lengths = x_line_lengths(&screen_reconfirm);
    println!("=== AFTER RE-CONFIRMING (should still be 40) ===");
    println!("x-line lengths: {:?}", reconfirm_lengths);
    println!("{}", screen_reconfirm);

    assert_eq!(
        reconfirm_lengths[0], 39,
        "After re-confirming without changes, width should still be 39 (40 minus reserved col), got {:?}",
        reconfirm_lengths,
    );

    // --- Set compose width to 60 by typing ---
    run_command(&mut harness, "Set Compose Width");
    harness.wait_for_prompt().unwrap();
    harness.type_text("60").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();
    settle(&mut harness);

    let screen_60 = harness.screen_to_string();
    let w60_lengths = x_line_lengths(&screen_60);
    println!("=== AFTER SET WIDTH TO 60 ===");
    println!("x-line lengths: {:?}", w60_lengths);
    println!("{}", screen_60);

    assert_eq!(
        w60_lengths[0], 59,
        "With compose width 60, first x-line should be 59 chars (1 col reserved for cursor), got {:?}",
        w60_lengths,
    );
}

/// Test that compose width can be changed after restoring a workspace session.
///
/// Reproduces a bug where a persisted compose_width (e.g. from a previous session)
/// is stuck and cannot be changed via the Set Compose Width command, because the
/// plugin's config.composeWidth is out of sync with the restored view state.
#[test]
fn test_compose_mode_width_survives_session_restore() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};
    use std::time::Duration;

    init_tracing_from_env();

    let long_line = "x".repeat(200);
    let md_content = format!("# Session Test\n\n{}\n", long_line);

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("session_test.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // A second file that is NOT in compose mode
    let other_path = project_root.join("other.txt");
    std::fs::write(
        &other_path,
        "This is a plain text file, not in compose mode.\n",
    )
    .unwrap();

    // Count consecutive x characters in each screen line that contains x's.
    // Handles both compose mode (no line numbers, just x's) and source mode
    // (line numbers like "3 │ xxxx").
    let x_line_lengths = |screen: &str| -> Vec<usize> {
        screen
            .lines()
            .filter_map(|l| {
                // Find the longest run of consecutive x's in the line
                let max_run = l
                    .chars()
                    .fold((0usize, 0usize), |(max, cur), c| {
                        if c == 'x' {
                            (max.max(cur + 1), cur + 1)
                        } else {
                            (max, 0)
                        }
                    })
                    .0;
                if max_run >= 10 {
                    Some(max_run)
                } else {
                    None
                }
            })
            .collect::<Vec<_>>()
    };

    let run_command = |h: &mut EditorTestHarness, name: &str| {
        h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
            .unwrap();
        h.wait_for_prompt().unwrap();
        h.type_text(name).unwrap();
        h.wait_for_screen_contains(name).unwrap();
        h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
    };

    let settle = |h: &mut EditorTestHarness| {
        for _ in 0..10 {
            h.process_async_and_render().unwrap();
            std::thread::sleep(Duration::from_millis(50));
            h.advance_time(Duration::from_millis(50));
        }
        h.render().unwrap();
    };

    // --- Session 1: open both files, enable compose on md, set width to 40, save ---
    {
        let mut harness = EditorTestHarness::with_config_and_working_dir(
            80,
            25,
            Default::default(),
            project_root.clone(),
        )
        .unwrap();

        harness.open_file(&other_path).unwrap();
        harness.render().unwrap();
        harness.open_file(&md_path).unwrap();
        harness.render().unwrap();

        run_command(&mut harness, "Toggle Compose");
        harness.wait_for_prompt_closed().unwrap();
        settle(&mut harness);

        run_command(&mut harness, "Set Compose Width");
        harness.wait_for_prompt().unwrap();
        harness.type_text("40").unwrap();
        harness
            .send_key(KeyCode::Enter, KeyModifiers::NONE)
            .unwrap();
        harness.wait_for_prompt_closed().unwrap();
        settle(&mut harness);

        let screen = harness.screen_to_string();
        let lengths = x_line_lengths(&screen);
        println!("=== SESSION 1: width=40 ===");
        println!("x-line lengths: {:?}", lengths);
        println!("{}", screen);
        assert_eq!(
            lengths[0], 39,
            "Session 1: width should render 39 (40 minus 1 col reserved for EOL cursor), got {:?}",
            lengths,
        );

        harness.editor_mut().save_workspace().unwrap();
    }

    // --- Session 2: restore, verify both buffers, then change compose width ---
    {
        let mut harness = EditorTestHarness::with_config_and_working_dir(
            80,
            25,
            Default::default(),
            project_root.clone(),
        )
        .unwrap();

        harness.editor_mut().try_restore_workspace().unwrap();
        settle(&mut harness);

        // The md file should be active (it was last opened) and in compose mode
        let screen_restored = harness.screen_to_string();
        let restored_lengths = x_line_lengths(&screen_restored);
        println!("=== SESSION 2: md file after restore ===");
        println!("x-line lengths: {:?}", restored_lengths);
        println!("{}", screen_restored);
        assert!(
            !restored_lengths.is_empty(),
            "After restore, should see x-lines on screen.\nScreen:\n{}",
            screen_restored,
        );
        assert_eq!(
            restored_lengths[0], 39,
            "After restore, compose width should still render 39 (40 minus reserved col), got {:?}.\nScreen:\n{}",
            restored_lengths, screen_restored,
        );

        // Switch to the other (non-compose) buffer and verify it's normal
        harness.open_file(&other_path).unwrap();
        settle(&mut harness);
        let screen_other = harness.screen_to_string();
        println!("=== SESSION 2: other.txt (non-compose) ===");
        println!("{}", screen_other);
        assert!(
            screen_other.contains("plain text file"),
            "other.txt should show its content normally.\nScreen:\n{}",
            screen_other,
        );

        // Switch back to the md file
        harness.open_file(&md_path).unwrap();
        settle(&mut harness);

        // Now try to change width to 60
        run_command(&mut harness, "Set Compose Width");
        harness.wait_for_prompt().unwrap();
        harness.type_text("60").unwrap();
        harness
            .send_key(KeyCode::Enter, KeyModifiers::NONE)
            .unwrap();
        harness.wait_for_prompt_closed().unwrap();
        settle(&mut harness);

        let screen_60 = harness.screen_to_string();
        let w60_lengths = x_line_lengths(&screen_60);
        println!("=== SESSION 2: after changing to 60 ===");
        println!("x-line lengths: {:?}", w60_lengths);
        println!("{}", screen_60);
        assert_eq!(
            w60_lengths[0], 59,
            "After changing width to 60 in restored session, expected 59 (60 minus reserved col), got {:?}",
            w60_lengths,
        );
    }
}

/// Test that "Toggle Compose/Preview (All Files)" enables and disables
/// compose mode for ALL open markdown buffers, not just the active one.
///
/// This is a regression test for a bug where setViewMode/setLineNumbers
/// only operated on the active buffer via Deref, causing non-active
/// buffers to remain in compose mode when toggling OFF.
#[test]
fn test_toggle_compose_all_affects_all_buffers() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Set up project with the markdown_compose plugin
    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    // Create two markdown files
    let md_one = project_root.join("one.md");
    std::fs::write(&md_one, "# File One\n\nContent one.\n").unwrap();
    let md_two = project_root.join("two.md");
    std::fs::write(&md_two, "# File Two\n\nContent two.\n").unwrap();

    // Width 120 (not 100) because the status bar's right side now includes
    // the color-coded "LSP (off)" dormant-indicator for any language with
    // a default LSP config (markdown has marksman), which truncates the
    // "Markdown Compose (All Files): ON/OFF" status message at 100 cols
    // and causes the `wait_until_stable` substring polls below to spin
    // until test timeout.
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
            .unwrap();

    // Open both files (two.md will be active)
    harness.open_file(&md_one).unwrap();
    harness.open_file(&md_two).unwrap();
    harness.render().unwrap();
    harness.assert_screen_contains("File Two");

    // Both should start in source mode (line numbers visible)
    let screen = harness.screen_to_string();
    assert!(
        screen.contains("│"),
        "Source mode should show line number separator: {}",
        screen
    );

    // Toggle compose all ON via command palette
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose/Preview (All").unwrap();
    harness
        .wait_for_screen_contains("Toggle Compose/Preview (All")
        .unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to activate
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("Compose (All Files): ON")
        })
        .unwrap();

    // Active buffer (two.md) should be in compose mode (no line numbers)
    let screen = harness.screen_to_string();
    // In compose mode, the line number column with │ separator is hidden
    let content_lines: Vec<&str> = screen
        .lines()
        .filter(|l| l.contains("File Two") || l.contains("Content two"))
        .collect();
    for line in &content_lines {
        assert!(
            !line.contains("│"),
            "Compose mode should hide line numbers, but got: {}",
            line
        );
    }

    // Switch to one.md and verify it's also in compose mode
    harness.open_file(&md_one).unwrap();
    harness.render().unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("File One"))
        .unwrap();

    let screen = harness.screen_to_string();
    let content_lines: Vec<&str> = screen
        .lines()
        .filter(|l| l.contains("File One") || l.contains("Content one"))
        .collect();
    for line in &content_lines {
        assert!(
            !line.contains("│"),
            "one.md should also be in compose mode after toggle all ON, but got: {}",
            line
        );
    }

    // Switch back to two.md before toggling off
    harness.open_file(&md_two).unwrap();
    harness.render().unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("File Two"))
        .unwrap();

    // Toggle compose all OFF
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose/Preview (All").unwrap();
    harness
        .wait_for_screen_contains("Toggle Compose/Preview (All")
        .unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for source mode to restore
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("Compose (All Files): OFF")
        })
        .unwrap();

    // Active buffer (two.md) should be back in source mode (line numbers)
    let screen = harness.screen_to_string();
    let content_lines: Vec<&str> = screen.lines().filter(|l| l.contains("File Two")).collect();
    assert!(
        content_lines.iter().any(|l| l.contains("│")),
        "two.md should be back in source mode with line numbers after toggle all OFF: {}",
        screen
    );

    // Switch to one.md and verify it's also back in source mode
    harness.open_file(&md_one).unwrap();
    harness.render().unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("File One"))
        .unwrap();

    let screen = harness.screen_to_string();
    let content_lines: Vec<&str> = screen.lines().filter(|l| l.contains("File One")).collect();
    assert!(
        content_lines.iter().any(|l| l.contains("│")),
        "one.md should also be back in source mode after toggle all OFF: {}",
        screen
    );
}

/// Regression test: compose-mode tables must fit within the editor
/// viewport whether or not the File Explorer sidebar is open.
///
/// Bug: when a separator cell (`|---...---|`) was wider than the
/// allocated column width, the cell-wide truncate conceal and the
/// per-`-` conceals overlapped — the first `-` of each cell emitted
/// `─` (from the per-char conceal) AND the cell-wide conceal emitted
/// its N-char replacement, producing N+1 rendered chars per cell. The
/// extra characters caused the separator to wrap onto a second line
/// and visually misalign the table. Opening the sidebar made the
/// symptom obvious: the smaller viewport forced truncation on a table
/// that previously fit, exposing the off-by-one.
#[test]
fn test_compose_mode_table_width_respects_file_explorer() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    // Table whose natural (un-constrained) width is bigger than the editor
    // content area once the File Explorer sidebar is open. This forces
    // `distributeColumnWidths` to shrink columns — but only if the cached
    // `allocated` widths are recomputed against the new viewport width.
    //
    // At 120 col terminal with no sidebar, available = 120-5 = 115, so the
    // natural widths (~28*4+5 = 117) are re-distributed down slightly.
    // After the sidebar takes ~34 cols, available drops to ~80, so columns
    // must get substantially narrower.
    let md_content = "\
# Table Width Bug

| ColumnAAAAAAAAAAAAAAAAAAAAAAAAAA | ColumnBBBBBBBBBBBBBBBBBBBBBBBBBB | ColumnCCCCCCCCCCCCCCCCCCCCCCCCCC | ColumnDDDDDDDDDDDDDDDDDDDDDDDDDD |
|----------------------------------|----------------------------------|----------------------------------|----------------------------------|
| alphaaaaaaaaaaaaaaaaaaaaaaaaaaaa | bravooooooooooooooooooooooooooo  | charlieeeeeeeeeeeeeeeeeeeeeeeee  | deltaaaaaaaaaaaaaaaaaaaaaaaaaa   |
| echooooooooooooooooooooooooooo   | foxtrotttttttttttttttttttttttttt | golffffffffffffffffffffffffffff  | hoteleleleleeeeeeeeeeeeeeeeeeeee |
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("table_width.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Use a wide terminal so the unopened-sidebar path has plenty of room.
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(120, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Wait for compose mode to render the table (box-drawing chars visible).
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    // Capture the table-row span (leftmost→rightmost delimiter column)
    // before the sidebar is opened. We use the TABLE-ROW signature (row
    // containing "Column" text + box-drawing pipes), so we don't pick up
    // sidebar borders.
    let screen_before = harness.screen_to_string();
    let (min_before, max_before) = table_column_span_on_content_rows(&screen_before);
    let width_before = max_before - min_before;
    assert!(
        width_before > 60,
        "Sanity: table should span a meaningful width before the sidebar \
         is opened; got span {}..{}.\nScreen:\n{}",
        min_before,
        max_before,
        screen_before,
    );

    // Also: the separator row between header and first data row must fit
    // on a single line (no wrap). If the separator overflows the editor
    // content area it will get wrapped, producing a row without both
    // ├ and ┤ — detect that explicitly.
    assert!(
        separator_row_fits(&screen_before),
        "Separator row doesn't fit on one line BEFORE the sidebar is \
         opened.\nScreen:\n{}",
        screen_before,
    );

    // Open the File Explorer sidebar.
    harness.editor_mut().toggle_file_explorer();
    harness
        .wait_until_stable(|h| h.editor().file_explorer_visible())
        .unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("File Explorer"))
        .unwrap();

    // Let compose mode settle again after the viewport change.
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    let screen_after = harness.screen_to_string();
    let (min_after, max_after) = table_column_span_on_content_rows(&screen_after);
    let width_after = max_after - min_after;

    // After the sidebar opens, the editor content area shrinks. The table
    // must shrink to fit — natural widths are larger than the new editor
    // area so `distributeColumnWidths` must re-distribute.
    assert!(
        width_after < width_before,
        "Table width did not shrink when the File Explorer sidebar was \
         opened. Table span before={}..{} ({}), after={}..{} ({}). The \
         cached table column widths were not recomputed against the \
         smaller editor content area.\n\
         Screen after opening sidebar:\n{}",
        min_before,
        max_before,
        width_before,
        min_after,
        max_after,
        width_after,
        screen_after,
    );

    // And the separator must still fit on a single line in the now-
    // smaller editor area.
    assert!(
        separator_row_fits(&screen_after),
        "Separator row doesn't fit on one line AFTER the sidebar is \
         opened — it's being rendered wider than the editor content area \
         and wraps.\nScreen:\n{}",
        screen_after,
    );
}

/// (leftmost, rightmost) column of table delimiters. We use the table's
/// top/bottom borders (rows containing the table-only glyphs `┬` or `┴`)
/// — these never appear in the File Explorer pane, whose vertical border
/// uses only `│` `┌` `┐` `└` `┘` `─`.
fn table_column_span_on_content_rows(screen: &str) -> (usize, usize) {
    let mut min_col = usize::MAX;
    let mut max_col = 0usize;
    for line in screen.lines() {
        let is_table_border = line.contains('┬') || line.contains('┴');
        if !is_table_border {
            continue;
        }
        for (i, c) in line.chars().enumerate() {
            if matches!(c, '┌' | '┐' | '└' | '┘' | '┬' | '┴') {
                if i < min_col {
                    min_col = i;
                }
                if i > max_col {
                    max_col = i;
                }
            }
        }
    }
    if min_col == usize::MAX {
        (0, 0)
    } else {
        (min_col, max_col)
    }
}

/// True iff every inter-row separator of the table fits on a single
/// screen line. Each separator row opens with `├`; a well-rendered one
/// also closes with `┤` on the same line. If the separator overflows the
/// viewport it gets wrapped onto a second line, and the closing `┤`
/// appears on the next line — the opening line has `├` but no `┤`.
fn separator_row_fits(screen: &str) -> bool {
    let mut any = false;
    for line in screen.lines() {
        if line.contains('├') {
            any = true;
            if !line.contains('┤') {
                return false;
            }
        }
    }
    any
}

/// Regression test: when an explicit compose width is set that's wider
/// than the available editor area (e.g. compose width 80 in a terminal
/// where the File Explorer sidebar leaves only ~60 cols for the editor),
/// the table must clamp its column allocation to the editor area and
/// not overflow into the sidebar.
#[test]
fn test_compose_mode_table_width_clamped_when_sidebar_opens() {
    use crate::common::harness::{copy_plugin, copy_plugin_lib};
    use crate::common::tracing::init_tracing_from_env;
    use crossterm::event::{KeyCode, KeyModifiers};

    init_tracing_from_env();

    let md_content = "\
# Compose Width Clamp

| ColumnA                          | ColumnB                          | ColumnC                          | ColumnD                          |
|----------------------------------|----------------------------------|----------------------------------|----------------------------------|
| alphaaaaaaaaaaaaaaaaaaaaaaaaaaaa | bravooooooooooooooooooooooooooo  | charlieeeeeeeeeeeeeeeeeeeeeeeee  | deltaaaaaaaaaaaaaaaaaaaaaaaaaa   |
| echooooooooooooooooooooooooooo   | foxtrotttttttttttttttttttttttttt | golffffffffffffffffffffffffffff  | hoteleleleleeeeeeeeeeeeeeeeeeeee |
";

    let temp_dir = tempfile::TempDir::new().unwrap();
    let project_root = temp_dir.path().join("project");
    std::fs::create_dir(&project_root).unwrap();

    let plugins_dir = project_root.join("plugins");
    std::fs::create_dir(&plugins_dir).unwrap();
    copy_plugin(&plugins_dir, "markdown_compose");
    copy_plugin_lib(&plugins_dir);

    let md_path = project_root.join("clamp.md");
    std::fs::write(&md_path, &md_content).unwrap();

    // Terminal just slightly wider than the compose width we'll set (80).
    // After the sidebar takes ~30 cols, the editor area will be much
    // narrower than 80 — the plugin must clamp.
    let mut harness =
        EditorTestHarness::with_config_and_working_dir(90, 30, Default::default(), project_root)
            .unwrap();

    harness.open_file(&md_path).unwrap();
    harness.render().unwrap();

    // Enable compose mode
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Toggle Compose").unwrap();
    harness.wait_for_screen_contains("Toggle Compose").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            s.contains("│") && s.contains("─")
        })
        .unwrap();

    // Set compose width explicitly to 80
    harness
        .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    harness.type_text("Set Compose Width").unwrap();
    harness
        .wait_for_screen_contains("Set Compose Width")
        .unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt().unwrap();
    // The prompt comes up pre-filled (e.g. with "None"); clear then type.
    harness
        .send_key(KeyCode::Char('a'), KeyModifiers::CONTROL)
        .unwrap();
    harness.type_text("80").unwrap();
    harness
        .send_key(KeyCode::Enter, KeyModifiers::NONE)
        .unwrap();
    harness.wait_for_prompt_closed().unwrap();

    // Let the new compose width settle.
    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    // Open File Explorer
    harness.editor_mut().toggle_file_explorer();
    harness
        .wait_until_stable(|h| h.editor().file_explorer_visible())
        .unwrap();
    harness
        .wait_until_stable(|h| h.screen_to_string().contains("File Explorer"))
        .unwrap();

    let mut prev = String::new();
    harness
        .wait_until_stable(|h| {
            let s = h.screen_to_string();
            let stable = s == prev;
            prev = s;
            stable
        })
        .unwrap();

    let screen = harness.screen_to_string();

    // The table must fit on a single line — no separator row should wrap.
    assert!(
        separator_row_fits(&screen),
        "With compose width=80 and the File Explorer sidebar open on a \
         90-col terminal, the table separator row overflows the editor \
         content area and wraps. The plugin is using the configured \
         compose width (80) instead of clamping it to the smaller \
         editor area.\nScreen:\n{}",
        screen,
    );
}
