retach 0.10.0

Persistent terminal sessions with native scrollback passthrough
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
//! Tests for live scrollback delivery while client is connected.
//!
//! Simulates the scenario where output arrives continuously and the client's
//! terminal may be scrolled up viewing history. Covers:
//! - Pending scrollback draining + render_with_scrollback correctness
//! - Cache invalidation after scrollback injection
//! - Multiple sequential scrollback batches (no duplication/loss)
//! - Mixed scrollback and screen-only update cycles
//! - Mode and scroll region restoration after scrollback injection
//! - Rapid output accumulation in pending scrollback
//! - Interleaved scrollback / no-scrollback render cycles

use super::test_helpers::*;
use super::*;
use render::AnsiRenderer;

// ─── Helpers ────────────────────────────────────────────────────────────────

/// Collect pending scrollback as trimmed strings.
fn pending_texts(pending: &[Vec<u8>]) -> Vec<String> {
    pending.iter().map(|b| strip_ansi(b)).collect()
}

/// Simulate a throttled render cycle: drain pending scrollback and render.
/// Returns the rendered bytes.
fn do_render_cycle(screen: &mut Screen, cache: &mut AnsiRenderer) -> Vec<u8> {
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(screen, &pending_rows);
    if !pending.is_empty() {
        cache.render_with_scrollback(screen, &pending)
    } else {
        cache.render(screen, false)
    }
}

// ─── take_pending_scrollback returns rows ───────────────────────────────────

#[test]
fn take_pending_scrollback_returns_rows_and_advances_pending() {
    let mut screen = Screen::new(10, 3, 100);
    screen.process(b"aaa\r\nbbb\r\nccc\r\nddd\r\neee");
    assert_eq!(screen.grid.scrollback_len(), 2);

    let rows = screen.take_pending_scrollback();
    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0][0].c, 'a');
    assert_eq!(rows[1][0].c, 'b');

    assert!(screen.take_pending_scrollback().is_empty());

    screen.process(b"\r\nfff");
    let rows = screen.take_pending_scrollback();
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0][0].c, 'c');

    // Eviction-at-limit: with a small scrollback limit, pending rows that
    // overflow the limit are evicted before the drain. The drain must return
    // only the surviving window (most recent lines), without panicking.
    let mut small = Screen::new(10, 3, 2);
    // 3 visible rows; 6 lines scroll off (ggg..lll, visible = mmm/nnn/ooo).
    // Pending is capped at the limit of 2, keeping the two most recent
    // scrolled-off lines (kkk, lll).
    small.process(b"ggg\r\nhhh\r\niii\r\njjj\r\nkkk\r\nlll\r\nmmm\r\nnnn\r\nooo");
    assert_eq!(small.grid.scrollback_len(), 2);
    let rows = small.take_pending_scrollback();
    assert_eq!(rows.len(), 2, "pending capped at scrollback limit");
    assert_eq!(rows[0][0].c, 'k', "oldest surviving pending line");
    assert_eq!(rows[1][0].c, 'l', "most recent scrolled-off line");
    assert!(small.take_pending_scrollback().is_empty());
}

// ─── Cache invalidation after scrollback injection ──────────────────────────

#[test]
fn incremental_render_after_scrollback_injection_redraws_all_rows() {
    // After render_with_scrollback invalidates the cache, the first
    // incremental render should emit all rows (cache sentinel mismatch).
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // Initial content: fill exactly 4 rows
    screen.process(b"AAAA\r\nBBBB\r\nCCCC\r\nDDDD");
    let _ = cache.render(&screen, false);

    // More output scrolls lines into pending scrollback
    screen.process(b"\r\nEEEE\r\nFFFF\r\nGGGG");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 3, "3 lines should have scrolled off");

    // render_with_scrollback invalidates the cache
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);
    // Should contain scrollback before screen clear and screen after
    assert!(text.contains("\x1b[2J"), "should have screen clear");

    // Now: incremental render with NO changes should skip all rows
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        !incr_text.contains("\x1b[1;1H"),
        "no row redraws when nothing changed after scrollback injection"
    );
    assert!(
        !incr_text.contains("\x1b[2;1H"),
        "no row redraws when nothing changed after scrollback injection"
    );
}

#[test]
fn two_incremental_renders_after_scrollback_only_first_redraws() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Fill and scroll
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    let _ = cache.render_with_scrollback(&screen, &pending);

    // Modify one row
    screen.process(b"\rMODIFIED");

    // First incremental: should redraw the modified row
    let r1 = cache.render(&screen, false);
    let t1 = String::from_utf8_lossy(&r1);
    assert!(
        t1.contains("MODIFIED"),
        "modified row should be in first incremental"
    );
    assert!(t1.contains("\x1b[3;1H"), "bottom row should be redrawn");

    // Second incremental: nothing changed, no redraws
    let r2 = cache.render(&screen, false);
    let t2 = String::from_utf8_lossy(&r2);
    assert!(
        !t2.contains("\x1b[1;1H") && !t2.contains("\x1b[2;1H") && !t2.contains("\x1b[3;1H"),
        "no row redraws on second unchanged incremental"
    );
}

// ─── Multiple scrollback batches ────────────────────────────────────────────

#[test]
fn sequential_scrollback_batches_no_duplication() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Initial: fill screen
    screen.process(b"R01\r\nR02\r\nR03");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Batch 1: 3 more lines → 3 scroll off
    screen.process(b"\r\nR04\r\nR05\r\nR06");
    let pending1_rows = screen.take_pending_scrollback();
    let pending1 = cache.render_rows(&screen, &pending1_rows);
    let texts1 = pending_texts(&pending1);
    assert_eq!(texts1.len(), 3, "batch 1: 3 lines scrolled off");
    assert!(texts1[0].contains("R01"));
    assert!(texts1[2].contains("R03"));

    let out1 = cache.render_with_scrollback(&screen, &pending1);
    let t1 = String::from_utf8_lossy(&out1);
    // Scrollback should have R01-R03, screen should have R04-R06
    let pos_clear1 = t1.find("\x1b[2J").unwrap();
    assert!(
        t1[..pos_clear1].contains("R01"),
        "batch1: R01 in scrollback"
    );
    assert!(t1[pos_clear1..].contains("R04"), "batch1: R04 on screen");

    // Batch 2: 2 more lines → 2 scroll off
    screen.process(b"\r\nR07\r\nR08");
    let pending2_rows = screen.take_pending_scrollback();
    let pending2 = cache.render_rows(&screen, &pending2_rows);
    let texts2 = pending_texts(&pending2);
    assert_eq!(texts2.len(), 2, "batch 2: 2 lines scrolled off");
    assert!(
        texts2[0].contains("R04"),
        "batch2 pending should start from R04"
    );
    assert!(texts2[1].contains("R05"), "batch2 pending should have R05");

    let out2 = cache.render_with_scrollback(&screen, &pending2);
    let t2 = String::from_utf8_lossy(&out2);
    let pos_clear2 = t2.find("\x1b[2J").unwrap();
    // Only new pending in scrollback portion
    assert!(
        t2[..pos_clear2].contains("R04"),
        "batch2: R04 in scrollback"
    );
    assert!(
        t2[..pos_clear2].contains("R05"),
        "batch2: R05 in scrollback"
    );
    assert!(
        !t2[..pos_clear2].contains("R01"),
        "batch2: R01 should NOT be in this scrollback (already sent)"
    );
    // Screen should show R06-R08
    assert!(t2[pos_clear2..].contains("R06"), "batch2: R06 on screen");
    assert!(t2[pos_clear2..].contains("R08"), "batch2: R08 on screen");

    // Total history should have all 5 scrolled lines
    let hist = history_texts(&screen);
    assert_eq!(hist.len(), 5, "total history should be 5 lines");
    for i in 1..=5 {
        assert!(
            hist[i - 1].contains(&format!("R{:02}", i)),
            "history[{}] should be R{:02}, got: '{}'",
            i - 1,
            i,
            hist[i - 1]
        );
    }
}

#[test]
fn many_sequential_batches_accumulate_correctly() {
    let mut screen = Screen::new(20, 3, 1000);
    let mut cache = AnsiRenderer::new();
    let mut total_pending_sent = 0;

    // Fill screen initially
    screen.process(b"init1\r\ninit2\r\ninit3");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // 10 batches, each producing 5 new lines (scrolling 5 off)
    for batch in 0..10 {
        for j in 0..5 {
            let n = 4 + batch * 5 + j; // numbering continues from init
            screen.process(format!("\r\nB{:03}", n).as_bytes());
        }

        let pending_rows = screen.take_pending_scrollback();
        let pending = cache.render_rows(&screen, &pending_rows);
        assert!(
            !pending.is_empty(),
            "batch {} should have pending scrollback",
            batch
        );
        total_pending_sent += pending.len();

        let _ = cache.render_with_scrollback(&screen, &pending);
    }

    // Total scrollback should match what we sent
    let hist = history_texts(&screen);
    assert_eq!(
        hist.len(),
        total_pending_sent,
        "total history should match total pending sent across all batches"
    );
}

// ─── Mixed scrollback and screen-only changes ───────────────────────────────

#[test]
fn screen_only_change_after_scrollback_renders_incrementally() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // Fill and scroll
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF\r\nG\r\nH");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Screen-only change: overwrite part of current line (no scroll)
    screen.process(b"\rCHANGED");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert!(
        pending.is_empty(),
        "cursor overwrite should not generate scrollback"
    );

    // Incremental render: only changed row
    let output = cache.render(&screen, false);
    let text = String::from_utf8_lossy(&output);
    assert!(text.contains("CHANGED"), "changed content should appear");
    // Should be an incremental render (no screen clear)
    assert!(
        !text.contains("\x1b[2J"),
        "screen-only change should not clear screen"
    );
}

#[test]
fn alternating_scrollback_and_no_scrollback_cycles() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Fill screen
    screen.process(b"line1\r\nline2\r\nline3");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Cycle 1: scrollback
    screen.process(b"\r\nnew1\r\nnew2");
    let out1 = do_render_cycle(&mut screen, &mut cache);
    let t1 = String::from_utf8_lossy(&out1);
    assert!(
        t1.contains("\x1b[2J"),
        "scrollback cycle should screen clear"
    );

    // Cycle 2: screen-only (cursor move + overwrite)
    screen.process(b"\x1b[1;1H"); // move to top
    screen.process(b"OVER");
    let out2 = do_render_cycle(&mut screen, &mut cache);
    let t2 = String::from_utf8_lossy(&out2);
    assert!(
        !t2.contains("\x1b[2J"),
        "screen-only cycle should not clear"
    );
    assert!(t2.contains("OVER"), "overwrite should appear");

    // Cycle 3: scrollback again
    screen.process(b"\x1b[3;1H"); // cursor to bottom
    screen.process(b"\r\nnew3\r\nnew4\r\nnew5");
    let out3 = do_render_cycle(&mut screen, &mut cache);
    let t3 = String::from_utf8_lossy(&out3);
    assert!(
        t3.contains("\x1b[2J"),
        "scrollback cycle should screen clear again"
    );

    // Cycle 4: no changes at all
    let out4 = do_render_cycle(&mut screen, &mut cache);
    let t4 = String::from_utf8_lossy(&out4);
    assert!(!t4.contains("\x1b[2J"), "no-change cycle should not clear");
    assert!(
        !t4.contains("\x1b[1;1H") && !t4.contains("\x1b[2;1H") && !t4.contains("\x1b[3;1H"),
        "no-change cycle should not redraw rows"
    );
}

// ─── Mode delta correctness across scrollback/incremental transitions ───────
//
// render_with_scrollback forces full=true, so modes are always emitted.
// The real test: does the cache correctly track modes so that SUBSEQUENT
// incremental renders produce correct deltas?

#[test]
fn cursor_shape_delta_correct_after_scrollback_then_change() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Set cursor shape to bar, fill and scroll
    screen.process(b"\x1b[5 q"); // blinking bar
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let _ = do_render_cycle(&mut screen, &mut cache);
    // Cache now has cursor_shape = blinking bar

    // Change cursor shape to block
    screen.process(b"\x1b[2 q"); // steady block

    // Incremental render should emit ONLY the new shape (delta from bar → block)
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        incr_text.contains("\x1b[2 q"),
        "incremental should emit new cursor shape (block)"
    );
    assert!(
        !incr_text.contains("\x1b[5 q"),
        "incremental should NOT re-emit old cursor shape (bar)"
    );

    // Next incremental with no change: should NOT emit cursor shape at all
    let incr2 = cache.render(&screen, false);
    let incr2_text = String::from_utf8_lossy(&incr2);
    assert!(
        !incr2_text.contains(" q"),
        "no cursor shape emission when nothing changed"
    );
}

#[test]
fn bracketed_paste_delta_after_scrollback_cycle() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Enable bracketed paste, scroll
    screen.process(b"\x1b[?2004h");
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let _ = do_render_cycle(&mut screen, &mut cache);
    // Cache now has bracketed_paste = true

    // Incremental with no mode change: should NOT re-emit ?2004h
    let incr1 = cache.render(&screen, false);
    let incr1_text = String::from_utf8_lossy(&incr1);
    assert!(
        !incr1_text.contains("?2004"),
        "no mode change → should not re-emit bracketed paste"
    );

    // Disable bracketed paste
    screen.process(b"\x1b[?2004l");
    let incr2 = cache.render(&screen, false);
    let incr2_text = String::from_utf8_lossy(&incr2);
    assert!(
        incr2_text.contains("\x1b[?2004l"),
        "disabling bracketed paste should emit ?2004l in delta"
    );
}

#[test]
fn autowrap_delta_after_scrollback_cycle() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Disable autowrap, scroll
    screen.process(b"\x1b[?7l");
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let _ = do_render_cycle(&mut screen, &mut cache);
    // Cache has autowrap = false

    // Re-enable autowrap
    screen.process(b"\x1b[?7h");
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        incr_text.contains("\x1b[?7h"),
        "re-enabling autowrap should appear in incremental delta after scrollback"
    );

    // No further change
    let incr2 = cache.render(&screen, false);
    let incr2_text = String::from_utf8_lossy(&incr2);
    assert!(
        !incr2_text.contains("?7"),
        "no autowrap change → should not re-emit"
    );
}

// ─── Scroll region cache correctness across scrollback/incremental ──────────

#[test]
fn scroll_region_cached_after_scrollback_not_re_emitted() {
    let mut screen = Screen::new(20, 5, 100);
    let mut cache = AnsiRenderer::new();

    // Set custom scroll region, fill and scroll
    screen.process(b"\x1b[2;4r");
    screen.process(b"\x1b[H");
    for i in 1..=10 {
        screen.process(format!("L{:02}\r\n", i).as_bytes());
    }

    // Scrollback render: emits scroll region, caches it
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Incremental render: scroll region unchanged → should NOT re-emit
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        !incr_text.contains("\x1b[2;4r"),
        "unchanged scroll region should NOT be re-emitted on incremental render"
    );

    // Change scroll region
    screen.process(b"\x1b[1;3r");
    let incr2 = cache.render(&screen, false);
    let incr2_text = String::from_utf8_lossy(&incr2);
    assert!(
        incr2_text.contains("\x1b[1;3r"),
        "changed scroll region should be emitted on incremental render"
    );
}

// ─── Cursor position after scrollback injection ─────────────────────────────

#[test]
fn cursor_position_correct_after_scrollback_injection() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // Fill screen and then scroll
    screen.process(b"row1\r\nrow2\r\nrow3\r\nrow4");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // More output → scrollback
    screen.process(b"\r\nrow5\r\nrow6");
    // Cursor should be at row 3 (0-indexed), col 4 (after "row6")
    assert_eq!(screen.grid.cursor_y(), 3);
    assert_eq!(screen.grid.cursor_x(), 4);

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // CUP: ESC[4;5H (1-indexed)
    assert!(
        text.contains("\x1b[4;5H"),
        "cursor should be at row 4, col 5 (1-indexed) after scrollback injection, got: {}",
        text.chars().collect::<String>().replace('\x1b', "ESC")
    );
}

// ─── Rapid output accumulation ──────────────────────────────────────────────

#[test]
fn large_pending_scrollback_renders_all_lines() {
    let mut screen = Screen::new(20, 3, 5000);
    let mut cache = AnsiRenderer::new();

    // Fill screen initially
    screen.process(b"init1\r\ninit2\r\ninit3");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Rapid burst: 500 lines arrive at once (simulates `cat large_file`)
    for i in 1..=500 {
        screen.process(format!("\r\nR{:04}", i).as_bytes());
    }

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(
        pending.len(),
        500,
        "all 500 scrolled lines should be in pending"
    );

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // All scrollback lines should be present before screen clear
    let pos_clear = text.find("\x1b[2J").expect("should have screen clear");
    let scrollback_portion = &text[..pos_clear];
    assert!(
        scrollback_portion.contains("R0001"),
        "first line should be in scrollback"
    );
    assert!(
        scrollback_portion.contains("R0250"),
        "middle line should be in scrollback"
    );
    assert!(
        scrollback_portion.contains("R0497"),
        "last scrolled-off line should be in scrollback"
    );

    // Screen should show the last 3 lines (R0498, R0499, R0500)
    let screen_portion = &text[pos_clear..];
    assert!(screen_portion.contains("R0498"), "R0498 on screen");
    assert!(screen_portion.contains("R0500"), "R0500 on screen");
}

#[test]
fn pending_scrollback_limit_enforced_during_rapid_output() {
    let limit = 50;
    let mut screen = Screen::new(20, 3, limit);
    let mut cache = AnsiRenderer::new();

    screen.process(b"A\r\nB\r\nC");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // 200 lines → pending capped at limit
    for i in 1..=200 {
        screen.process(format!("\r\nL{:04}", i).as_bytes());
    }

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(
        pending.len(),
        limit,
        "pending should be capped at scrollback limit"
    );

    // The pending should contain the MOST RECENT lines that scrolled off
    let texts = pending_texts(&pending);
    // Screen has 3 rows. After 200 \r\n lines, screen shows L0198/L0199/L0200.
    // 200 lines scrolled off total: A, B, C, L0001...L0197.
    // Pending is capped at 50, keeping most recent → L0148...L0197.
    assert!(
        texts.first().unwrap().contains("L0148"),
        "first pending should be L0148 (oldest kept), got: '{}'",
        texts.first().unwrap()
    );
    assert!(
        texts.last().unwrap().contains("L0197"),
        "last pending should be L0197 (last scrolled-off line), got: '{}'",
        texts.last().unwrap()
    );
}

// ─── Synchronized output wrapping ───────────────────────────────────────────

#[test]
fn scrollback_injection_outside_synchronized_output() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Scrollback injection must be BEFORE the sync block so that terminals
    // (like Blink/hterm) process scroll operations immediately.
    let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
    let pos_a = text.find("A").unwrap();
    assert!(
        pos_a < sync_begin,
        "scrollback content should appear before sync block"
    );
    assert!(
        text.ends_with("\x1b[?2026l"),
        "output should end with synchronized output end"
    );
}

// ─── Scrollback injection overwrites rows then scrolls via \n ───────────────

#[test]
fn scrollback_injection_starts_at_bottom_row() {
    let mut screen = Screen::new(20, 5, 100);
    let mut cache = AnsiRenderer::new();

    // Use distinctive labels to avoid matching stray bytes
    screen.process(b"SCRLL1\r\nSCRLL2\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert!(!pending.is_empty());

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Scrollback content should be present and before the sync block
    let pos_first_scrollback = text.find("SCRLL1").expect("SCRLL1 missing");
    let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
    assert!(
        pos_first_scrollback < sync_begin,
        "scrollback content should precede sync block"
    );

    // The bottom-row positioning (for \n scroll) should appear after the content
    // and before the sync block
    assert!(
        text.contains("\x1b[5;1H"),
        "should position cursor at bottom row for scrolling"
    );
}

// ─── Content ordering: scrollback before clear, screen after clear ──────────

#[test]
fn scrollback_content_before_clear_screen_content_after() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Write 8 lines on 3-row screen
    for i in 1..=8 {
        if i < 8 {
            screen.process(format!("LINE{:02}\r\n", i).as_bytes());
        } else {
            screen.process(format!("LINE{:02}", i).as_bytes());
        }
    }

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    let texts = pending_texts(&pending);
    assert_eq!(texts.len(), 5, "5 lines should be pending");

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);
    let pos_clear = text.find("\x1b[2J").unwrap();

    let before_screen = &text[..pos_clear];
    let after_screen = &text[pos_clear..];

    // All 5 scrollback lines should be before screen clear
    for i in 1..=5 {
        let label = format!("LINE{:02}", i);
        assert!(
            before_screen.contains(&label),
            "{} should be in scrollback (before screen clear)",
            label
        );
        assert!(
            !after_screen.contains(&label),
            "{} should NOT be in screen portion (after screen clear)",
            label
        );
    }

    // Last 3 lines should be on screen (after screen clear)
    for i in 6..=8 {
        let label = format!("LINE{:02}", i);
        assert!(
            after_screen.contains(&label),
            "{} should be on screen (after screen clear)",
            label
        );
    }
}

// ─── Simulate the full relay cycle ──────────────────────────────────────────

#[test]
fn full_relay_cycle_scrollback_then_incremental_then_scrollback() {
    // Simulates the screen_to_client relay loop:
    // cycle 1: output arrives, pending scrollback → render_with_scrollback
    // cycle 2: cursor movement only → incremental render
    // cycle 3: more output, scrollback again → render_with_scrollback
    // Verifies each cycle produces correct output.

    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // Initial fill
    screen.process(b"A001\r\nA002\r\nA003\r\nA004");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // --- Cycle 1: scrollback ---
    screen.process(b"\r\nB005\r\nB006\r\nB007");
    let out1 = do_render_cycle(&mut screen, &mut cache);
    let t1 = String::from_utf8_lossy(&out1);
    assert!(
        t1.contains("\x1b[2J"),
        "cycle 1 should screen clear (scrollback)"
    );
    let pos_clear1 = t1.find("\x1b[2J").unwrap();
    assert!(
        t1[..pos_clear1].contains("A001"),
        "cycle 1: A001 in scrollback"
    );
    assert!(t1[pos_clear1..].contains("B007"), "cycle 1: B007 on screen");

    // --- Cycle 2: cursor move only ---
    screen.process(b"\x1b[1;1H"); // cursor to top-left
    let out2 = do_render_cycle(&mut screen, &mut cache);
    let t2 = String::from_utf8_lossy(&out2);
    assert!(
        !t2.contains("\x1b[2J"),
        "cycle 2 should not clear (no scrollback)"
    );
    // Cursor should be repositioned
    assert!(t2.contains("\x1b[1;1H"), "cycle 2: cursor at top-left");

    // --- Cycle 3: more scrollback ---
    screen.process(b"\x1b[4;1H"); // cursor back to bottom
    screen.process(b"\r\nC008\r\nC009");
    let out3 = do_render_cycle(&mut screen, &mut cache);
    let t3 = String::from_utf8_lossy(&out3);
    assert!(
        t3.contains("\x1b[2J"),
        "cycle 3 should screen clear (scrollback)"
    );
    let pos_clear3 = t3.find("\x1b[2J").unwrap();
    // Only newly scrolled lines in this batch's scrollback
    assert!(
        !t3[..pos_clear3].contains("A001"),
        "cycle 3: A001 already sent, should not be in this batch"
    );
    assert!(t3[pos_clear3..].contains("C009"), "cycle 3: C009 on screen");
}

// ─── Edge case: single-line scrollback ──────────────────────────────────────

#[test]
fn single_line_scrollback_injection() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"line1\r\nline2\r\nline3");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // One more line → one line scrolls off
    screen.process(b"\r\nline4");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 1, "exactly one line should scroll off");

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    assert!(text.contains("\x1b[2J"), "should have screen clear");
    let pos_clear = text.find("\x1b[2J").unwrap();
    assert!(
        text[..pos_clear].contains("line1"),
        "single scrollback line should be present"
    );
    assert!(text[pos_clear..].contains("line4"), "line4 on screen");
}

// ─── Edge case: scrollback with styled content ──────────────────────────────

#[test]
fn styled_scrollback_lines_preserve_formatting() {
    let mut screen = Screen::new(30, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Write styled content that will scroll off
    screen.process(b"\x1b[1;31mBOLD_RED\x1b[0m\r\n");
    screen.process(b"\x1b[4;32mUNDERLINE_GREEN\x1b[0m\r\n");
    screen.process(b"plain\r\n");
    screen.process(b"visible1\r\n");
    screen.process(b"visible2");

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 2, "2 styled lines should scroll off");

    // Pending lines should have specific SGR codes
    let line0 = String::from_utf8_lossy(&pending[0]);
    assert!(line0.contains("BOLD_RED"), "content should be preserved");
    // render_line uses to_sgr_with_reset which emits "0;attr;color" format
    // Bold (1) and red fg (31) should both be present
    assert!(
        line0.contains(";1;") && line0.contains(";31m"),
        "bold+red SGR codes should be preserved in scrollback, got: '{}'",
        line0
    );

    let line1 = String::from_utf8_lossy(&pending[1]);
    assert!(
        line1.contains("UNDERLINE_GREEN"),
        "content should be preserved"
    );
    // Underline (4) and green fg (32) should both be present
    assert!(
        line1.contains(";4;") && line1.contains(";32m"),
        "underline+green SGR codes should be preserved in scrollback, got: '{}'",
        line1
    );

    // Render with scrollback should include the styled lines
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);
    assert!(text.contains("BOLD_RED"), "styled content in render output");
    assert!(
        text.contains("UNDERLINE_GREEN"),
        "styled content in render output"
    );
}

// ─── Title cache correctness across scrollback/incremental ──────────────────

#[test]
fn title_cached_after_scrollback_not_re_emitted() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Set title, scroll → render_with_scrollback caches the title
    screen.process(b"\x1b]2;My Terminal\x07");
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Incremental render: title unchanged → should NOT re-emit
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        !incr_text.contains("My Terminal"),
        "unchanged title should NOT be re-emitted on incremental render"
    );

    // Change title
    screen.process(b"\x1b]2;New Title\x07");
    let incr2 = cache.render(&screen, false);
    let incr2_text = String::from_utf8_lossy(&incr2);
    assert!(
        incr2_text.contains("\x1b]2;New Title\x07"),
        "changed title should be emitted on incremental render"
    );
    assert!(
        !incr2_text.contains("My Terminal"),
        "old title should not appear"
    );
}

// ─── Cursor visibility preserved ────────────────────────────────────────────

#[test]
fn cursor_hidden_state_preserved_after_scrollback_injection() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Hide cursor then produce output
    screen.process(b"\x1b[?25l");
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // The render hides cursor at the start (?25l is always emitted early).
    // If cursor_visible is false, ?25h should NOT appear anywhere in the output.
    let show_count = text.matches("\x1b[?25h").count();
    assert_eq!(
        show_count, 0,
        "cursor show (?25h) should not appear when cursor is hidden"
    );

    // Verify the render DOES contain the hide sequence
    assert!(
        text.contains("\x1b[?25l"),
        "cursor hide should be present in render"
    );
}

// ─── Pending scrollback empty after drain ───────────────────────────────────

#[test]
fn pending_empty_after_drain_no_double_send() {
    let mut screen = Screen::new(20, 3, 100);

    screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF");

    let first_rows = screen.take_pending_scrollback();
    let first = AnsiRenderer::new().render_rows(&screen, &first_rows);
    assert_eq!(first.len(), 3, "first drain should have 3 lines");

    let second_rows = screen.take_pending_scrollback();
    let second = AnsiRenderer::new().render_rows(&screen, &second_rows);
    assert!(second.is_empty(), "second drain should be empty");

    // But get_history still returns all scrollback
    assert_eq!(
        screen.get_history().len(),
        3,
        "history should still have 3 lines after drain"
    );
}

// ─── Output arriving between take_pending and render ────────────────────────

#[test]
fn output_between_take_and_render_captured_in_next_cycle() {
    // In the real server, the screen lock is held across take+render.
    // But this test verifies that if new output arrives after take_pending
    // but before the next cycle, it's correctly captured.

    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");

    // Take pending (2 lines: A, B scrolled off the 3-row screen)
    let pending1_rows = screen.take_pending_scrollback();
    let pending1 = cache.render_rows(&screen, &pending1_rows);
    assert_eq!(pending1.len(), 2);

    // More output arrives before we render
    screen.process(b"\r\nF\r\nG");

    // Render with first batch (the new output is NOT in this render's scrollback)
    let _ = cache.render_with_scrollback(&screen, &pending1);

    // Next cycle: take new pending
    // After first take (A, B), we processed \r\nF\r\nG which scrolled off C and D.
    let pending2_rows = screen.take_pending_scrollback();
    let pending2 = cache.render_rows(&screen, &pending2_rows);
    assert_eq!(
        pending2.len(),
        2,
        "new lines should be in next pending batch"
    );
    let texts2 = pending_texts(&pending2);
    assert!(texts2[0].contains("C"), "pending2 should have C");
    assert!(texts2[1].contains("D"), "pending2 should have D");
}

// ─── Alt screen interaction with pending scrollback ─────────────────────────

#[test]
fn alt_screen_does_not_generate_scrollback() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Fill screen in main mode
    screen.process(b"main1\r\nmain2\r\nmain3");
    let _ = do_render_cycle(&mut screen, &mut cache);

    // Enter alt screen
    screen.process(b"\x1b[?1049h");

    // Write many lines in alt screen — should NOT generate scrollback
    for i in 1..=20 {
        screen.process(format!("alt{:02}\r\n", i).as_bytes());
    }

    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert!(
        pending.is_empty(),
        "alt screen output should NOT generate pending scrollback, got {} lines",
        pending.len()
    );

    let hist = screen.get_history();
    assert!(
        hist.is_empty(),
        "alt screen output should NOT generate history, got {} lines",
        hist.len()
    );
}

#[test]
fn pending_scrollback_preserved_across_alt_screen_excursion() {
    let mut screen = Screen::new(20, 3, 100);

    // Generate scrollback in main mode
    screen.process(b"L01\r\nL02\r\nL03\r\nL04\r\nL05");
    // Pending should have 2 lines (L01, L02)

    // Enter alt screen (like vim opening)
    screen.process(b"\x1b[?1049h");
    screen.process(b"alt content\r\nalt line 2\r\nalt line 3\r\nalt line 4");

    // Exit alt screen (vim closing — restores main grid)
    screen.process(b"\x1b[?1049l");

    // The pending scrollback from main mode should still be there
    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(
        pending.len(),
        2,
        "pending from before alt screen should survive the excursion"
    );
    let texts = pending_texts(&pending);
    assert!(texts[0].contains("L01"), "first pending should be L01");
    assert!(texts[1].contains("L02"), "second pending should be L02");
}

// ─── Non-zero scroll region does NOT generate scrollback ────────────────────

#[test]
fn scroll_region_not_at_top_produces_no_scrollback() {
    let mut screen = Screen::new(20, 5, 100);

    // Set scroll region to rows 2-4 (not starting at top)
    screen.process(b"\x1b[2;4r");
    screen.process(b"\x1b[2;1H"); // cursor to row 2

    // Write many lines inside scroll region — they scroll within the region
    for i in 1..=20 {
        if i < 20 {
            screen.process(format!("SR{:02}\r\n", i).as_bytes());
        } else {
            screen.process(format!("SR{:02}", i).as_bytes());
        }
    }

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert!(
        pending.is_empty(),
        "scrolling within non-top scroll region should NOT generate scrollback, got {} lines",
        pending.len()
    );

    let hist = screen.get_history();
    assert!(
        hist.is_empty(),
        "scrolling within non-top scroll region should NOT generate history"
    );

    // Row 0 (outside scroll region) should be untouched
    assert_eq!(
        screen.grid.visible_row(0)[0].c,
        ' ',
        "row above scroll region should be blank"
    );
}

// ─── Wide characters in scrollback ──────────────────────────────────────────

#[test]
fn wide_chars_in_scrollback_render_correctly() {
    let mut screen = Screen::new(20, 3, 100);

    // Write lines with wide characters that will scroll off
    screen.process("你好世界\r\n".as_bytes());
    screen.process("テスト\r\n".as_bytes());
    screen.process("plain\r\n".as_bytes());
    screen.process("visible1\r\n".as_bytes());
    screen.process("visible2".as_bytes());

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 2, "2 lines should scroll off");

    // Verify the rendered scrollback contains the wide characters
    let line0 = String::from_utf8_lossy(&pending[0]);
    assert!(
        line0.contains("你好世界"),
        "wide chars should be preserved in scrollback render, got: '{}'",
        line0
    );

    let line1 = String::from_utf8_lossy(&pending[1]);
    assert!(
        line1.contains("テスト"),
        "wide chars should be preserved in scrollback render, got: '{}'",
        line1
    );

    // Render with scrollback should not crash or produce garbage
    let mut cache = AnsiRenderer::new();
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);
    assert!(
        text.contains("你好世界"),
        "wide chars in scrollback render output"
    );
    assert!(
        text.contains("テスト"),
        "wide chars in scrollback render output"
    );
}

// ─── Empty/blank lines in scrollback ────────────────────────────────────────

#[test]
fn blank_lines_in_scrollback_produce_empty_entries() {
    let mut screen = Screen::new(20, 3, 100);

    // Write blank lines that will scroll off
    screen.process(b"\r\n\r\n\r\nvisible1\r\nvisible2\r\nvisible3");
    // 3 blank lines scroll off, then visible1 scrolls off too

    let pending_rows = screen.take_pending_scrollback();
    let pending = AnsiRenderer::new().render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 3, "3 lines should scroll off");

    // Blank lines should produce empty rendered entries
    assert!(
        pending[0].is_empty(),
        "blank scrollback line should render as empty, got {} bytes",
        pending[0].len()
    );
    assert!(
        pending[1].is_empty(),
        "blank scrollback line should render as empty, got {} bytes",
        pending[1].len()
    );

    // render_with_scrollback should handle empty entries without panicking
    let mut cache = AnsiRenderer::new();
    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Scrollback portion is everything before the sync block
    let sync_pos = text.find("\x1b[?2026h").expect("should have sync begin");
    let scrollback_portion = &text[..sync_pos];

    // Each scrollback entry (even blank) produces a row + EL via CUP positioning,
    // then \n at the bottom to scroll. Count \n in scrollback portion.
    let newline_count = scrollback_portion.matches('\n').count();
    assert!(
        newline_count >= pending.len(),
        "each scrollback entry (even blank) should produce a scroll \\n, got {} for {} entries",
        newline_count,
        pending.len()
    );
}

// ─── Mode change between scrollback batches ─────────────────────────────────

#[test]
fn mode_change_between_scrollback_batches_reflected_correctly() {
    // Real scenario: app enables bracketed paste, output scrolls,
    // then app disables bracketed paste, more output scrolls.
    // Each render cycle should reflect the mode state at that time.
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Batch 1: bracketed paste ON, scroll
    screen.process(b"\x1b[?2004h");
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE");
    let out1 = do_render_cycle(&mut screen, &mut cache);
    let t1 = String::from_utf8_lossy(&out1);
    assert!(
        t1.contains("\x1b[?2004h"),
        "batch 1: bracketed paste should be ON"
    );

    // Batch 2: disable bracketed paste, more scroll
    screen.process(b"\x1b[?2004l");
    screen.process(b"\r\nF\r\nG");
    let out2 = do_render_cycle(&mut screen, &mut cache);
    let t2 = String::from_utf8_lossy(&out2);
    // Full render (scrollback) emits all modes — bracketed paste should be OFF
    assert!(
        t2.contains("\x1b[?2004l"),
        "batch 2: bracketed paste should be OFF"
    );
    assert!(
        !t2.contains("\x1b[?2004h"),
        "batch 2: bracketed paste ON should NOT appear"
    );

    // Incremental render: no change → no mode emission
    let incr = cache.render(&screen, false);
    let incr_text = String::from_utf8_lossy(&incr);
    assert!(
        !incr_text.contains("?2004"),
        "incremental after batch 2: no mode change → no emission"
    );
}

// ─── Overwrite-then-scroll algorithm tests ──────────────────────────────────

/// Scrollback injection must reset the scroll region (`\x1b[r`) before
/// emitting \n at the bottom row, so that a custom DECSTBM from a previous
/// render doesn't confine the scroll to a sub-region.
#[test]
fn scrollback_injection_resets_scroll_region() {
    let mut screen = Screen::new(20, 5, 100);
    let mut cache = AnsiRenderer::new();

    // Set a custom scroll region (rows 2-4)
    screen.process(b"\x1b[2;4r");
    // Generate some scrollback
    screen.process(b"\x1b[r"); // reset for output
    screen.process(b"A\r\nB\r\nC\r\nD\r\nE\r\nF\r\nG");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert!(!pending.is_empty());

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // \x1b[r (DECSTBM reset to full screen) must appear before the scrollback
    // content and before the bottom-row positioning
    let reset_pos = text.find("\x1b[r").expect("scroll region reset missing");
    let first_content = text.find("A").unwrap_or(text.len());
    assert!(
        reset_pos < first_content,
        "scroll region reset (pos {}) must precede scrollback content (pos {})",
        reset_pos,
        first_content
    );
}

/// When scrollback fits in a single chunk (scrollback.len() <= rows),
/// each line should be written to the correct row via CUP before scrolling.
#[test]
fn scrollback_single_chunk_overwrites_rows() {
    let mut screen = Screen::new(20, 5, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"AA\r\nBB\r\nCC\r\nDD\r\nEE\r\nFF\r\nGG");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 2, "2 lines should scroll off");

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Lines should be positioned at rows 1 and 2 via CUP
    assert!(
        text.contains("\x1b[1;1H"),
        "row 1 positioning for first scrollback line"
    );
    assert!(
        text.contains("\x1b[2;1H"),
        "row 2 positioning for second scrollback line"
    );

    // Content should appear in correct order
    let pos_aa = text.find("AA").expect("AA missing");
    let pos_bb = text.find("BB").expect("BB missing");
    assert!(pos_aa < pos_bb, "AA should appear before BB");

    // EL (\x1b[K) should follow each line to clear remainder
    let raw = output;
    let aa_pos = raw.windows(2).position(|w| w == b"AA").unwrap();
    assert_eq!(
        &raw[aa_pos + 2..aa_pos + 5],
        b"\x1b[K",
        "EL should follow scrollback line content"
    );
}

/// When scrollback exceeds visible rows, it should be processed in chunks.
/// Each chunk overwrites rows then scrolls. Final native scrollback must
/// contain all lines without duplication.
#[test]
fn scrollback_multi_chunk_processes_correctly() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    // Generate 8 lines of scrollback (3 rows visible, so 3 chunks: 3+3+2)
    screen.process(b"L01\r\nL02\r\nL03\r\nL04\r\nL05\r\nL06\r\nL07\r\nL08\r\nV01\r\nV02\r\nV03");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 8, "8 lines should scroll off");

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // All 8 lines should appear in the output (overwritten to rows)
    for i in 1..=8 {
        let label = format!("L{:02}", i);
        assert!(
            text.contains(&label),
            "{} should be in scrollback output",
            label
        );
    }

    // Each chunk should have bottom-row positioning (\x1b[3;1H) followed by \n
    // There should be 3 bottom-row positionings (3 chunks)
    let bottom_count = text.matches("\x1b[3;1H").count();
    assert!(
        bottom_count >= 3,
        "expected at least 3 bottom-row positionings for 3 chunks, got {}",
        bottom_count
    );

    // All scrollback content must be before the sync block
    let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
    let last_scrollback = text.rfind("L08").expect("L08 missing");
    assert!(
        last_scrollback < sync_begin,
        "all scrollback content must precede sync block"
    );

    // Visible content should be in the sync block (after screen clear)
    let pos_clear = text.find("\x1b[2J").expect("screen clear missing");
    let after_clear = &text[pos_clear..];
    assert!(
        after_clear.contains("V01"),
        "V01 should be in screen portion"
    );
    assert!(
        after_clear.contains("V03"),
        "V03 should be in screen portion"
    );
}

/// Partial last chunk should erase remaining rows below the content to prevent
/// stale data from leaking into native scrollback.
#[test]
fn scrollback_partial_chunk_erases_remaining_rows() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // 5 scrollback lines with 4 rows → chunk1(4) + chunk2(1)
    screen.process(b"S1\r\nS2\r\nS3\r\nS4\r\nS5\r\nV1\r\nV2\r\nV3\r\nV4");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 5);

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // In the second chunk (1 line), rows 2-4 should be erased (\x1b[2K)
    // to prevent stale content from leaking into scrollback.
    // Count EL2 (\x1b[...H\x1b[2K) sequences — should have at least 3
    // (rows 2, 3, 4 in the partial chunk).
    let el2_count = text.matches("\x1b[2K").count();
    assert!(
        el2_count >= 3,
        "partial chunk should erase at least 3 remaining rows, got {} EL2 sequences",
        el2_count
    );
}

/// Exactly `rows` scrollback lines should be handled in a single chunk
/// with no row erasure (no partial chunk).
#[test]
fn scrollback_exactly_rows_lines_single_chunk() {
    let mut screen = Screen::new(20, 4, 100);
    let mut cache = AnsiRenderer::new();

    // Exactly 4 scrollback lines with 4 visible rows
    screen.process(b"E1\r\nE2\r\nE3\r\nE4\r\nV1\r\nV2\r\nV3\r\nV4");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 4);

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // All lines present
    for i in 1..=4 {
        let label = format!("E{}", i);
        assert!(text.contains(&label), "{} missing from output", label);
    }

    // No EL2 (\x1b[2K) should appear — all rows filled, no partial chunk
    let el2_count = text.matches("\x1b[2K").count();
    assert_eq!(
        el2_count, 0,
        "full chunk should not erase any rows, got {} EL2 sequences",
        el2_count
    );

    // Bottom-row positioning should appear exactly once (one chunk)
    let bottom_positions = text.matches("\x1b[4;1H").count();
    assert!(
        bottom_positions >= 1,
        "should have bottom-row positioning for the single chunk"
    );
}

/// Scrollback injection with a single line should still overwrite row 1
/// and scroll via \n at the bottom.
#[test]
fn scrollback_single_line_overwrites_and_scrolls() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"ONLY\r\nV1\r\nV2\r\nV3");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 1);

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // Row 1 should have the content
    assert!(text.contains("\x1b[1;1H"), "should position at row 1");
    assert!(text.contains("ONLY"), "scrollback content missing");

    // Rows 2-3 should be erased (partial chunk: 1 line, 3 rows)
    let el2_count = text.matches("\x1b[2K").count();
    assert!(
        el2_count >= 2,
        "should erase rows 2-3, got {} EL2 sequences",
        el2_count
    );

    // Bottom-row positioning + \n for scrolling
    assert!(
        text.contains("\x1b[3;1H"),
        "should position at bottom row for scroll"
    );

    // All before sync block
    let sync_begin = text.find("\x1b[?2026h").unwrap();
    let content_pos = text.find("ONLY").unwrap();
    assert!(
        content_pos < sync_begin,
        "scrollback should precede sync block"
    );
}

/// Verify the output byte ordering is correct end-to-end:
/// [hide cursor + reset scroll region] → [overwrite+scroll chunks] → [sync begin] → [clear+redraw] → [sync end]
#[test]
fn scrollback_byte_ordering_end_to_end() {
    let mut screen = Screen::new(20, 3, 100);
    let mut cache = AnsiRenderer::new();

    screen.process(b"X1\r\nX2\r\nX3\r\nX4\r\nVIS");
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // 1. Starts with cursor hide
    assert!(
        text.starts_with("\x1b[?25l"),
        "should start with cursor hide"
    );

    // 2. Scroll region reset before content
    let reset_pos = text.find("\x1b[r").expect("scroll region reset missing");

    // 3. Content before sync block
    let content_pos = text.find("X1").expect("X1 missing");
    assert!(reset_pos < content_pos, "reset before content");

    // 4. Sync block after all scrollback
    let sync_begin = text.find("\x1b[?2026h").expect("sync begin missing");
    let last_content = text.rfind("X2").expect("X2 missing");
    assert!(last_content < sync_begin, "content before sync");

    // 5. Screen clear inside sync block
    let pos_clear = text.find("\x1b[2J").expect("screen clear missing");
    assert!(sync_begin < pos_clear, "screen clear inside sync block");

    // 6. Ends with sync end
    assert!(text.ends_with("\x1b[?2026l"), "should end with sync end");
}

/// Large burst: simulate 100+ lines scrolling in one render cycle.
/// Verifies chunking handles large amounts correctly.
#[test]
fn scrollback_large_burst_chunked_correctly() {
    let mut screen = Screen::new(40, 5, 500);
    let mut cache = AnsiRenderer::new();

    // Generate 50 lines of scrollback (54 lines with \r\n → 50 scroll off)
    for i in 1..=54 {
        screen.process(format!("LINE{:03}\r\n", i).as_bytes());
    }
    let pending_rows = screen.take_pending_scrollback();
    let pending = cache.render_rows(&screen, &pending_rows);
    assert_eq!(pending.len(), 50);

    let output = cache.render_with_scrollback(&screen, &pending);
    let text = String::from_utf8_lossy(&output);

    // All 50 scrollback lines should appear
    for i in 1..=50 {
        let label = format!("LINE{:03}", i);
        assert!(
            text.contains(&label),
            "{} missing from scrollback output",
            label
        );
    }

    // 50 lines / 5 rows = 10 full chunks, no partial
    // Each chunk has a bottom-row positioning
    let bottom_count = text.matches("\x1b[5;1H").count();
    assert!(
        bottom_count >= 10,
        "expected at least 10 bottom-row positionings for 10 chunks, got {}",
        bottom_count
    );

    // No EL2 (all chunks are full)
    let el2_count = text.matches("\x1b[2K").count();
    assert_eq!(el2_count, 0, "full chunks should not erase rows");

    // Visible lines after screen clear
    let after_clear = &text[text.find("\x1b[2J").unwrap()..];
    assert!(after_clear.contains("LINE051"), "LINE051 should be visible");
    assert!(after_clear.contains("LINE054"), "LINE054 should be visible");
}