//! #162 — SU/SD/IL/DL (`scroll_region_lines`) must rotate the marker (#118/#158)
//! and selection (#3) anchors like `linefeed`/`reverse_index` already do. The
//! CSI line-editing verbs move primary content, so an anchor in the affected
//! region has to follow it or it points at the wrong line. Pre-existing gap
//! surfaced by #158's completeness pass; grounded against alacritty
//! `scroll_up_relative`/`scroll_down_relative` (selection + vi cursor) and
//! xterm.js marker `onInsert`/`onDelete`.

use justerm_core::{
    Engine, MarkerKind, MarkerPosition, SelectionSpan, SelectionType, Side, TermEvent,
};

/// Fill each of the `rows` screen rows with a distinct letter, cursor left at the
/// last row. (5 rows → a,b,c,d,e on rows 0..4.)
fn filled(cols: usize, rows: usize) -> Engine {
    let mut t = Engine::new(cols, rows);
    for i in 0..rows {
        if i > 0 {
            t.feed(b"\r\n");
        }
        t.feed(&[b'a' + i as u8]);
    }
    t
}

fn marker(id: justerm_core::MarkerId, row: usize) -> MarkerPosition {
    MarkerPosition {
        id,
        row,
        kind: MarkerKind::Plain,
    }
}

// ---- markers ---------------------------------------------------------------

/// SU (CSI S) scrolls the region up: content — and a marker anchored to it —
/// moves up one row.
#[test]
fn su_rotates_marker_up() {
    let mut t = filled(10, 5);
    let id = t.add_marker(2); // "c" at row 2
    t.feed(b"\x1b[1S"); // scroll up 1
    assert_eq!(t.frame().overlay.markers, vec![marker(id, 1)]);
}

/// SD (CSI T) scrolls the region down: the marker moves down one row.
#[test]
fn sd_rotates_marker_down() {
    let mut t = filled(10, 5);
    let id = t.add_marker(2);
    t.feed(b"\x1b[1T"); // scroll down 1
    assert_eq!(t.frame().overlay.markers, vec![marker(id, 3)]);
}

/// IL (CSI L) inserts blank lines at the cursor, scrolling `[cursor..=bottom]`
/// down. A marker below the cursor follows down; one above is untouched.
#[test]
fn il_rotates_marker_below_cursor_down() {
    let mut t = filled(10, 5);
    let above = t.add_marker(0); // "a", above the cursor
    let below = t.add_marker(3); // "d", below the cursor
    t.feed(b"\x1b[2;1H"); // cursor to row 1
    t.feed(b"\x1b[1L"); // insert 1 line at row 1
    let mut got = t.frame().overlay.markers;
    got.sort_by_key(|m| m.id.0);
    assert_eq!(got, vec![marker(above, 0), marker(below, 4)]);
}

/// DL (CSI M) deletes lines at the cursor, scrolling `[cursor..=bottom]` up. A
/// marker below the cursor moves up.
#[test]
fn dl_rotates_marker_below_cursor_up() {
    let mut t = filled(10, 5);
    let id = t.add_marker(3); // "d"
    t.feed(b"\x1b[2;1H"); // cursor to row 1
    t.feed(b"\x1b[1M"); // delete 1 line at row 1
    assert_eq!(t.frame().overlay.markers, vec![marker(id, 2)]);
}

/// A marker on the dropped edge leaves the buffer and is disposed (SU drops the
/// region top).
#[test]
fn su_disposes_marker_on_dropped_edge() {
    let mut t = filled(10, 5);
    t.add_marker(0); // region top — dropped by SU
    t.feed(b"\x1b[1S");
    assert!(t.frame().overlay.markers.is_empty());
}

/// Multi-line SU by N rotates by N (the single-line rotate composed N times).
#[test]
fn su_by_n_rotates_marker_by_n() {
    let mut t = filled(10, 5);
    let id = t.add_marker(3); // "d"
    t.feed(b"\x1b[2S"); // scroll up 2
    assert_eq!(t.frame().overlay.markers, vec![marker(id, 1)]);
}

/// Alt-screen SU/SD/IL/DL must NOT move primary markers — they anchor primary
/// content, which an alt-screen edit never touches (mirrors #158's guard).
#[test]
fn alt_screen_region_edit_leaves_primary_marker() {
    let mut t = filled(10, 5);
    let id = t.add_marker(2);
    t.feed(b"\x1b[?1049h"); // enter alt
    t.feed(b"\x1b[1S\x1b[1T\x1b[1L\x1b[1M"); // SU/SD/IL/DL on the alt screen
    t.feed(b"\x1b[?1049l"); // leave alt
    assert_eq!(t.frame().overlay.markers, vec![marker(id, 2)]);
}

/// #177 S0 (per-buffer markers): leaving the alt screen disposes the *alt*
/// marker list — empty while the alt guards stand, so it fires nothing today. A
/// primary marker survives the alt enter→leave excursion, and no spurious
/// `MarkerDisposed` is emitted. Locks in the dispose seam the alt-marker slices
/// (#187) build on.
#[test]
fn alt_leave_fires_no_spurious_marker_disposal() {
    let mut t = filled(10, 5);
    let id = t.add_marker(2); // primary marker on "c"
    t.drain_events(); // clear setup events

    t.feed(b"\x1b[?1049h"); // enter alt
    t.feed(b"\x1b[?1049l"); // leave alt → drains the (empty) alt marker list

    let disposed: Vec<_> = t
        .drain_events()
        .into_iter()
        .filter(|e| matches!(e, TermEvent::MarkerDisposed(_)))
        .collect();
    assert_eq!(disposed, vec![], "empty alt list → no disposal event");
    assert_eq!(
        t.frame().overlay.markers,
        vec![marker(id, 2)],
        "primary marker survives the excursion"
    );
}

// ---- selection (the identical pre-existing gap) ----------------------------

/// SU rotates a live selection up the same way — the selection is not left
/// pointing at the content that slid out from under it.
#[test]
fn su_rotates_selection_up() {
    let mut t = filled(10, 5);
    t.selection_begin(2, 0, Side::Left, SelectionType::Char);
    t.selection_extend(2, 3, Side::Right);
    t.feed(b"\x1b[1S");
    assert_eq!(
        t.frame().overlay.selection,
        vec![SelectionSpan {
            row: 1,
            left: 0,
            right: 3
        }]
    );
}

/// DL rotates the selection up from the cursor region too.
#[test]
fn dl_rotates_selection_up() {
    let mut t = filled(10, 5);
    t.selection_begin(3, 0, Side::Left, SelectionType::Char);
    t.selection_extend(3, 2, Side::Right);
    t.feed(b"\x1b[2;1H"); // cursor to row 1
    t.feed(b"\x1b[1M"); // delete 1 line
    assert_eq!(
        t.frame().overlay.selection,
        vec![SelectionSpan {
            row: 2,
            left: 0,
            right: 2
        }]
    );
}

// ---- end-to-end: the motivating scenario, mark from the *stream* (OSC 133) ----

/// The scenario #162 exists for, proven end-to-end from the VT stream (not
/// `add_marker`): a shell emits an OSC 133 command mark, then a multi-line prompt
/// redraw inserts a line above it (IL). The command mark must follow its content
/// down, not go stale — same rotate path as plain markers, but driven entirely by
/// bytes (OSC 133 parse → mark → CSI L parse → rotate).
#[test]
fn osc133_command_mark_rotates_on_insert_lines() {
    let mut t = filled(10, 5);
    t.feed(b"\x1b[4;1H\x1b]133;C\x07"); // cursor row 3, OSC 133 C = OutputStart mark
    t.feed(b"\x1b[2;1H\x1b[1L"); // cursor row 1, insert a line (prompt redraw)

    let marks = t.frame().overlay.markers;
    assert_eq!(marks.len(), 1);
    assert_eq!(
        marks[0].row, 4,
        "OutputStart mark followed the content down"
    );
    assert_eq!(marks[0].kind, MarkerKind::OutputStart);
}

// ---- #174: edge-drop CLAMP (was: clear whole selection) --------------------
// `selection_rotate_region` used to clear the entire selection when either
// endpoint landed on the dropped edge. alacritty `Selection::rotate` instead
// clamps the crossing endpoint to the region boundary and clears only on a true
// overtake (a single-line selection wholly on the dropped line, or the endpoints
// crossing). These mirror that policy.

/// The motivating case: anchor ABOVE the DL region, focus ON the dropped top
/// edge. The row above is untouched, so the still-valid part must survive — the
/// focus collapses onto the anchor's row instead of the whole selection clearing.
#[test]
fn dl_clamps_when_focus_on_dropped_edge_keeps_valid_part() {
    let mut t = filled(10, 5);
    t.selection_begin(0, 1, Side::Left, SelectionType::Char); // anchor row0 (above region)
    t.selection_extend(1, 3, Side::Right); // focus row1 (the dropped edge)
    t.feed(b"\x1b[2;1H"); // cursor row1
    t.feed(b"\x1b[1M"); // DL: region [row1,row4] up, row1 dropped

    assert_eq!(
        t.frame().overlay.selection,
        vec![SelectionSpan {
            row: 0,
            left: 1,
            right: 3
        }],
        "row0 part kept, not cleared"
    );
}

/// The upper endpoint sitting on the dropped top edge of a DECSTBM region clamps
/// to the region top (col 0), and the lower endpoint follows up — the selection
/// survives clamped rather than clearing.
#[test]
fn su_clamps_upper_endpoint_to_region_top() {
    let mut t = filled(10, 6); // rows a..f
    t.feed(b"\x1b[2;5r"); // DECSTBM rows 2..5 (1-based) = region [row1,row4]
    t.selection_begin(1, 2, Side::Left, SelectionType::Char); // anchor row1 = region top
    t.selection_extend(3, 4, Side::Right); // focus row3
    t.feed(b"\x1b[1S"); // SU: region up, row1 dropped

    assert_eq!(
        t.frame().overlay.selection,
        vec![
            SelectionSpan {
                row: 1,
                left: 0,
                right: 9
            }, // upper clamped to top, col 0 → full first row
            SelectionSpan {
                row: 2,
                left: 0,
                right: 4
            },
        ]
    );
}

/// A single-line selection entirely on the dropped line still clears — the whole
/// selection scrolled off (alacritty's overtake case, not a clamp).
#[test]
fn su_clears_single_line_selection_on_dropped_edge() {
    let mut t = filled(10, 5);
    t.selection_begin(0, 0, Side::Left, SelectionType::Char);
    t.selection_extend(0, 3, Side::Right); // whole selection on row0 = dropped edge
    t.feed(b"\x1b[1S"); // SU drops row0

    assert_eq!(t.frame().overlay.selection, vec![], "wholly scrolled off");
}

/// Down-scroll (SD): the lower endpoint on the dropped bottom edge clamps to the
/// region bottom (last col), the upper follows down. Survives clamped.
#[test]
fn sd_clamps_lower_endpoint_to_region_bottom() {
    let mut t = filled(10, 5);
    t.selection_begin(2, 1, Side::Left, SelectionType::Char); // anchor row2
    t.selection_extend(4, 3, Side::Right); // focus row4 = bottom edge
    t.feed(b"\x1b[1T"); // SD: region [row0,row4] down, row4 dropped

    assert_eq!(
        t.frame().overlay.selection,
        vec![
            SelectionSpan {
                row: 3,
                left: 1,
                right: 9
            },
            SelectionSpan {
                row: 4,
                left: 0,
                right: 9
            }, // lower clamped to bottom, last col
        ]
    );
}

/// A Block (rectangular) selection keeps its columns on a clamp — alacritty only
/// resets col/side to the region edge for non-Block selections.
#[test]
fn su_block_selection_keeps_columns_on_clamp() {
    let mut t = filled(10, 5);
    t.selection_begin(0, 2, Side::Left, SelectionType::Block); // anchor row0 = dropped edge
    t.selection_extend(2, 5, Side::Right); // focus row2
    t.feed(b"\x1b[1S"); // SU drops row0; upper endpoint clamps to top

    assert_eq!(
        t.frame().overlay.selection,
        vec![
            SelectionSpan {
                row: 0,
                left: 2,
                right: 5
            }, // columns 2..5 preserved (not reset to 0)
            SelectionSpan {
                row: 1,
                left: 2,
                right: 5
            },
        ]
    );
}
