retach 0.6.2

Persistent terminal sessions with native scrollback passthrough
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
//! Tests for the boundary between the current screen and scrollback history.
//!
//! Covers:
//! - Screen-level invariants: no overlap between history and visible grid
//! - Pending scrollback drain semantics (reattach simulation)
//! - Resize interactions with scrollback (expand/shrink/roundtrip)
//! - Resize between reattach cycles
//! - Protocol round-trip: History + ScreenUpdate encode/decode
//! - End-to-end: Screen → history + render → protocol → client stdout contract

use super::*;
use crate::protocol::{self, ServerMsg};
use render::RenderCache;

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

/// Collect visible grid rows as trimmed strings.
fn screen_lines(screen: &Screen) -> Vec<String> {
    screen
        .grid
        .visible_rows()
        .map(|row| {
            let s: String = row.iter().map(|c| c.c).collect();
            s.trim_end().to_string()
        })
        .collect()
}

/// Collect scrollback history as trimmed strings (rendered ANSI stripped to text).
fn history_texts(screen: &Screen) -> Vec<String> {
    screen
        .get_history()
        .iter()
        .map(|b| strip_ansi(b))
        .collect()
}

/// Strip ANSI escape sequences, returning only printable text.
fn strip_ansi(bytes: &[u8]) -> String {
    let s = String::from_utf8_lossy(bytes);
    let mut out = String::new();
    let mut in_esc = false;
    for ch in s.chars() {
        if in_esc {
            if ch.is_ascii_alphabetic() || ch == 'm' {
                in_esc = false;
            }
            continue;
        }
        if ch == '\x1b' {
            in_esc = true;
            continue;
        }
        if ch >= ' ' {
            out.push(ch);
        }
    }
    out.trim_end().to_string()
}

/// Collect all content (scrollback + visible) as ordered text lines.
fn all_content(screen: &Screen) -> Vec<String> {
    let mut lines = history_texts(screen);
    lines.extend(
        screen_lines(screen)
            .into_iter()
            .filter(|s| !s.is_empty()),
    );
    lines
}

/// Write N labeled lines ("L01\r\n", "L02\r\n", ..., "LNN") to the screen.
/// The last line has no trailing \r\n.
fn write_labeled_lines(screen: &mut Screen, count: usize) {
    for i in 1..=count {
        if i < count {
            screen.process(format!("L{:02}\r\n", i).as_bytes());
        } else {
            screen.process(format!("L{:02}", i).as_bytes());
        }
    }
}

/// Simulate the reattach flow: get history + build ScreenUpdate with flush newlines,
/// exactly as `send_initial_state` does. Returns (history_lines, screen_update_bytes).
fn simulate_reattach(screen: &Screen) -> (Vec<Vec<u8>>, Vec<u8>) {
    let hist = screen.get_history();
    let mut render_data = Vec::new();
    if !hist.is_empty() {
        // Position cursor at bottom row
        render_data.extend_from_slice(b"\x1b[");
        style::write_u16(&mut render_data, screen.grid.rows);
        render_data.extend_from_slice(b";1H");
        // Flush newlines
        render_data.extend(
            std::iter::repeat_n(b'\n', screen.grid.rows.saturating_sub(1) as usize),
        );
    }
    let mut cache = RenderCache::new();
    render_data.extend_from_slice(&screen.render(true, &mut cache));
    (hist, render_data)
}

/// Simulate what the client writes to stdout for a History message.
fn client_write_history(history: &[Vec<u8>]) -> Vec<u8> {
    let mut out = Vec::new();
    for line in history {
        out.extend_from_slice(line);
        out.extend_from_slice(b"\r\n");
    }
    out
}

// ─── Section 1: Screen-level unit tests ─────────────────────────────────────

#[test]
fn history_and_screen_no_overlap() {
    let mut screen = Screen::new(10, 3, 100);
    // Write 8 lines on a 3-row screen → 5 in scrollback, 3 on screen
    write_labeled_lines(&mut screen, 8);
    let _ = screen.take_pending_scrollback();

    let hist = history_texts(&screen);
    let visible = screen_lines(&screen);

    // History should have exactly 5 lines
    assert_eq!(hist.len(), 5, "expected 5 history lines, got {:?}", hist);
    // Screen should show the last 3
    assert!(
        visible[0].contains("L06"),
        "screen row 0 should be L06, got: '{}'",
        visible[0]
    );
    assert!(
        visible[2].contains("L08"),
        "screen row 2 should be L08, got: '{}'",
        visible[2]
    );

    // No line should appear in both
    for h in &hist {
        for v in &visible {
            if !v.is_empty() {
                assert_ne!(
                    h, v,
                    "line '{}' appears in both history and screen",
                    h
                );
            }
        }
    }
}

#[test]
fn pending_scrollback_drained_before_reattach() {
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 6);

    // First drain: should have pending lines
    let first = screen.take_pending_scrollback();
    assert!(
        !first.is_empty(),
        "first take should have pending scrollback"
    );

    // Second drain: should be empty (simulates send_initial_state drain)
    let second = screen.take_pending_scrollback();
    assert!(
        second.is_empty(),
        "second take should be empty after drain"
    );

    // But get_history() still returns all scrollback
    let hist = screen.get_history();
    assert!(
        !hist.is_empty(),
        "get_history should still return scrollback after pending drain"
    );
}

#[test]
fn history_ordering_preserved_with_many_lines() {
    let mut screen = Screen::new(20, 3, 5000);
    // Write 200 lines
    for i in 1..=200 {
        if i < 200 {
            screen.process(format!("LINE{:04}\r\n", i).as_bytes());
        } else {
            screen.process(format!("LINE{:04}", i).as_bytes());
        }
    }
    let _ = screen.take_pending_scrollback();

    let hist = history_texts(&screen);
    // 200 lines - 3 visible = 197 in scrollback
    assert_eq!(hist.len(), 197);

    // Verify ordering: each line should have a higher number than the previous
    for (i, line) in hist.iter().enumerate() {
        let expected = format!("LINE{:04}", i + 1);
        assert!(
            line.contains(&expected),
            "history line {} should contain '{}', got: '{}'",
            i,
            expected,
            line
        );
    }
}

#[test]
fn resize_expand_moves_scrollback_to_screen_no_duplication() {
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 10);
    let _ = screen.take_pending_scrollback();

    // State: scrollback=[L01..L07], screen=[L08, L09, L10]
    assert_eq!(screen.get_history().len(), 7);

    // Expand to 6 rows → should restore 3 lines from scrollback
    screen.resize(10, 6);

    let all = all_content(&screen);
    // Total should still be 10 unique lines, no duplicates
    assert_eq!(all.len(), 10, "total lines after expand: {:?}", all);
    for (i, line) in all.iter().enumerate() {
        let expected = format!("L{:02}", i + 1);
        assert!(
            line.contains(&expected),
            "line {} should be '{}', got: '{}'",
            i,
            expected,
            line
        );
    }

    // Scrollback should have shrunk
    assert_eq!(
        screen.get_history().len(),
        4,
        "scrollback should have 4 lines after restoring 3"
    );
}

#[test]
fn resize_shrink_then_expand_roundtrip_no_duplication() {
    let mut screen = Screen::new(10, 5, 100);
    write_labeled_lines(&mut screen, 8);
    let _ = screen.take_pending_scrollback();

    // State: scrollback=[L01..L03], screen=[L04..L08]
    let content_before = all_content(&screen);
    assert_eq!(content_before.len(), 8);

    // Shrink to 3 rows (drops bottom 2 screen rows L07, L08)
    screen.resize(10, 3);

    // Expand back to 5 rows (restores from scrollback)
    screen.resize(10, 5);

    let content_after = all_content(&screen);
    // L07, L08 were lost in shrink — they're gone from screen and not in scrollback.
    // But L01..L06 should be preserved without duplication.
    let unique: std::collections::HashSet<&String> = content_after.iter().collect();
    assert_eq!(
        unique.len(),
        content_after.len(),
        "no duplicates allowed after shrink/expand: {:?}",
        content_after
    );
}

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

    // First batch: scroll 2 lines off
    screen.process(b"A1\r\nA2\r\nA3\r\nA4\r\nA5");
    let batch1 = screen.take_pending_scrollback();
    let batch1_texts: Vec<String> = batch1.iter().map(|b| strip_ansi(b)).collect();
    assert_eq!(batch1_texts.len(), 2, "first batch: {:?}", batch1_texts);
    assert!(batch1_texts[0].contains("A1"));
    assert!(batch1_texts[1].contains("A2"));

    // Second batch: scroll 1 more line off
    screen.process(b"\r\nA6");
    let batch2 = screen.take_pending_scrollback();
    let batch2_texts: Vec<String> = batch2.iter().map(|b| strip_ansi(b)).collect();
    assert_eq!(batch2_texts.len(), 1, "second batch: {:?}", batch2_texts);
    assert!(
        batch2_texts[0].contains("A3"),
        "second batch should only have A3, got: '{}'",
        batch2_texts[0]
    );
}

#[test]
fn history_render_flush_newlines_match_rows() {
    for rows in [3u16, 5, 10, 24] {
        let mut screen = Screen::new(80, rows, 100);
        // Generate enough lines to have scrollback
        for i in 1..=(rows as usize + 5) {
            if i < (rows as usize + 5) {
                screen.process(format!("line{}\r\n", i).as_bytes());
            } else {
                screen.process(format!("line{}", i).as_bytes());
            }
        }
        let _ = screen.take_pending_scrollback();

        let (hist, screen_update) = simulate_reattach(&screen);
        assert!(!hist.is_empty(), "should have history for rows={}", rows);

        // The screen update should start with cursor positioning to bottom row,
        // then rows-1 newlines, then the render
        let expected_prefix = format!("\x1b[{};1H", rows);
        let prefix_bytes = expected_prefix.as_bytes();
        assert!(
            screen_update.starts_with(prefix_bytes),
            "rows={}: update should start with cursor-to-bottom '{}', got: {:?}",
            rows,
            expected_prefix,
            String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
        );

        // Count newlines between cursor positioning and sync begin
        let after_cursor = &screen_update[prefix_bytes.len()..];
        let newline_count = after_cursor.iter().take_while(|&&b| b == b'\n').count();
        assert_eq!(
            newline_count,
            (rows - 1) as usize,
            "rows={}: expected {} flush newlines, got {}",
            rows,
            rows - 1,
            newline_count
        );
    }
}

#[test]
fn resize_between_reattach_preserves_content() {
    let mut screen = Screen::new(10, 5, 100);
    write_labeled_lines(&mut screen, 12);
    let _ = screen.take_pending_scrollback();

    // State: scrollback=[L01..L07], screen=[L08..L12]
    let content_before = all_content(&screen);
    assert_eq!(content_before.len(), 12);

    // Simulate detach: take snapshot
    let (hist_before, _) = simulate_reattach(&screen);
    assert_eq!(hist_before.len(), 7);

    // Resize while "detached" (server-side resize, e.g., from another client or API)
    screen.resize(10, 3);

    // Simulate reattach with new size
    let _ = screen.take_pending_scrollback(); // drain stale
    let (hist_after, screen_update_after) = simulate_reattach(&screen);

    // Verify: scrollback + screen still form a coherent sequence (no duplication)
    let content_after = all_content(&screen);
    let unique: std::collections::HashSet<&String> = content_after.iter().collect();
    assert_eq!(
        unique.len(),
        content_after.len(),
        "no duplicates after resize between reattach: {:?}",
        content_after
    );

    // History should still have content
    assert!(
        !hist_after.is_empty(),
        "history should not be empty after resize"
    );

    // Screen update should have flush newlines (since history is non-empty)
    let update_text = String::from_utf8_lossy(&screen_update_after);
    assert!(
        update_text.contains("\x1b[3;1H"),
        "flush should position cursor at new bottom row (3)"
    );
}

#[test]
fn resize_expand_between_reattach_restores_scrollback() {
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 8);
    let _ = screen.take_pending_scrollback();

    // State: scrollback=[L01..L05], screen=[L06, L07, L08]
    assert_eq!(screen.get_history().len(), 5);

    // Simulate detach + resize to larger terminal
    screen.resize(10, 8);

    // 5 lines should be restored from scrollback
    assert_eq!(
        screen.get_history().len(),
        0,
        "all scrollback should be restored after expanding to 8 rows"
    );

    // All 8 lines should be on screen
    let visible = screen_lines(&screen);
    for i in 1..=8 {
        let expected = format!("L{:02}", i);
        assert!(
            visible.iter().any(|v| v.contains(&expected)),
            "L{:02} should be visible after expand, screen: {:?}",
            i,
            visible
        );
    }

    // Reattach: no history → no flush newlines
    let (hist, screen_update) = simulate_reattach(&screen);
    assert!(hist.is_empty(), "no history after full restore");
    // Should start with sync begin directly (no cursor-to-bottom + newlines)
    assert!(
        screen_update.starts_with(b"\x1b[?2026h"),
        "no-history reattach should start with sync begin"
    );
}

// ─── Section 2: Protocol round-trip tests ───────────────────────────────────

#[test]
fn history_message_round_trip() {
    let lines = vec![
        b"line one".to_vec(),
        b"line two with \x1b[1mbold\x1b[0m".to_vec(),
        b"line three".to_vec(),
    ];
    let msg = ServerMsg::History(lines.clone());
    let encoded = protocol::encode(&msg).unwrap();

    let (data, consumed) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
    assert_eq!(consumed, encoded.len());

    let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
    match decoded {
        ServerMsg::History(decoded_lines) => {
            assert_eq!(decoded_lines.len(), 3);
            assert_eq!(decoded_lines[0], b"line one");
            assert_eq!(decoded_lines[2], b"line three");
        }
        other => panic!("expected History, got {:?}", other),
    }
}

#[test]
fn screen_update_message_round_trip() {
    let update_data = b"\x1b[?2026h\x1b[?25l\x1b[2J\x1b[HHello\x1b[?2026l".to_vec();
    let msg = ServerMsg::ScreenUpdate(update_data.clone());
    let encoded = protocol::encode(&msg).unwrap();

    let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
    let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
    match decoded {
        ServerMsg::ScreenUpdate(decoded_data) => {
            assert_eq!(decoded_data, update_data);
        }
        other => panic!("expected ScreenUpdate, got {:?}", other),
    }
}

#[test]
fn history_chunking_round_trip() {
    // Simulate the chunking logic from send_initial_state
    let mut all_lines = Vec::new();
    for i in 0..500 {
        all_lines.push(format!("history line {:04}", i).into_bytes());
    }

    let size_limit = protocol::codec::MAX_FRAME_SIZE / 2;
    let mut chunks: Vec<Vec<Vec<u8>>> = Vec::new();
    let mut chunk = Vec::new();
    let mut chunk_size = 0;

    for line in &all_lines {
        let line_size = line.len() + 16;
        if chunk_size + line_size > size_limit && !chunk.is_empty() {
            chunks.push(std::mem::take(&mut chunk));
            chunk_size = 0;
        }
        chunk_size += line_size;
        chunk.push(line.clone());
    }
    if !chunk.is_empty() {
        chunks.push(chunk);
    }

    // Encode all chunks, then decode and reassemble
    let mut reassembled = Vec::new();
    for chunk_lines in &chunks {
        let msg = ServerMsg::History(chunk_lines.clone());
        let encoded = protocol::encode(&msg).unwrap();
        let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
        let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
        match decoded {
            ServerMsg::History(lines) => reassembled.extend(lines),
            other => panic!("expected History, got {:?}", other),
        }
    }

    assert_eq!(reassembled.len(), all_lines.len());
    for (i, line) in reassembled.iter().enumerate() {
        assert_eq!(
            line, &all_lines[i],
            "line {} mismatch after chunked round-trip",
            i
        );
    }
}

#[test]
fn scrollback_line_message_round_trip() {
    let line = b"\x1b[1mcolored output\x1b[0m".to_vec();
    let msg = ServerMsg::ScrollbackLine(line.clone());
    let encoded = protocol::encode(&msg).unwrap();

    let (data, _) = protocol::codec::decode_frame(&encoded).unwrap().unwrap();
    let decoded: ServerMsg = protocol::codec::decode(data).unwrap();
    match decoded {
        ServerMsg::ScrollbackLine(decoded_line) => {
            assert_eq!(decoded_line, line);
        }
        other => panic!("expected ScrollbackLine, got {:?}", other),
    }
}

// ─── Section 3: End-to-end reattach simulation ──────────────────────────────

#[test]
fn e2e_reattach_history_then_screen() {
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 8);
    let _ = screen.take_pending_scrollback();

    // Simulate server side: build History + ScreenUpdate
    let (hist, screen_update) = simulate_reattach(&screen);
    assert_eq!(hist.len(), 5, "should have 5 history lines");

    // Simulate client side: write history to stdout, then screen update
    let mut stdout = Vec::new();
    // Client processes History message
    for line in &hist {
        stdout.extend_from_slice(line);
        stdout.extend_from_slice(b"\r\n");
    }
    // Client processes ScreenUpdate message
    stdout.extend_from_slice(&screen_update);

    let stdout_text = String::from_utf8_lossy(&stdout);

    // History lines should appear before the screen clear
    let pos_l01 = stdout_text.find("L01").expect("L01 should be in output");
    let pos_l05 = stdout_text.find("L05").expect("L05 should be in output");
    let pos_clear = stdout_text.find("\x1b[2J").expect("screen clear should be in output");

    assert!(
        pos_l01 < pos_l05,
        "history lines should be in order"
    );
    assert!(
        pos_l05 < pos_clear,
        "history should appear before screen clear"
    );

    // Screen content should appear after the clear
    let after_clear = &stdout_text[pos_clear..];
    assert!(
        after_clear.contains("L06"),
        "screen should contain L06 after clear"
    );
    assert!(
        after_clear.contains("L08"),
        "screen should contain L08 after clear"
    );

    // History lines should NOT appear after the clear (no duplication)
    // L01-L05 should only be in the history portion
    for label in &["L01", "L02", "L03", "L04", "L05"] {
        assert!(
            !after_clear.contains(label),
            "'{}' should not appear in screen portion (after clear)",
            label
        );
    }
}

#[test]
fn e2e_reattach_no_history_no_flush() {
    let mut screen = Screen::new(10, 3, 100);
    // Only 2 lines — no scrollback
    screen.process(b"Hello\r\nWorld");

    let (hist, screen_update) = simulate_reattach(&screen);
    assert!(hist.is_empty(), "should have no history");

    // ScreenUpdate should not have cursor-to-bottom or flush newlines
    // It should start directly with sync begin
    assert!(
        screen_update.starts_with(b"\x1b[?2026h"),
        "no-history reattach should start with sync begin, got: {:?}",
        String::from_utf8_lossy(&screen_update[..20.min(screen_update.len())])
    );
}

#[test]
fn e2e_reattach_with_styled_history() {
    let mut screen = Screen::new(20, 3, 100);
    // Write styled content that will scroll into history
    screen.process(b"\x1b[1;31mRED BOLD\x1b[0m normal\r\n");
    screen.process(b"\x1b[32mGREEN\x1b[0m\r\n");
    screen.process(b"plain1\r\n");
    screen.process(b"plain2\r\n");
    screen.process(b"visible");
    let _ = screen.take_pending_scrollback();

    let (hist, _) = simulate_reattach(&screen);
    assert_eq!(hist.len(), 2, "2 lines should be in history");

    // Verify styled line is preserved in history
    let line0 = &hist[0];
    let line0_text = String::from_utf8_lossy(line0);
    assert!(
        line0_text.contains("RED BOLD"),
        "history should preserve text content"
    );
    // SGR codes should be present
    assert!(
        line0_text.contains("\x1b["),
        "history should preserve SGR escape codes"
    );
}

#[test]
fn e2e_reattach_protocol_encode_decode_sequence() {
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 6);
    let _ = screen.take_pending_scrollback();

    let (hist, screen_update) = simulate_reattach(&screen);

    // Encode the full message sequence as the server would
    let mut wire = Vec::new();
    wire.extend(
        protocol::encode(&ServerMsg::Connected {
            name: "test".into(),
            new_session: false,
        })
        .unwrap(),
    );
    if !hist.is_empty() {
        wire.extend(protocol::encode(&ServerMsg::History(hist)).unwrap());
    }
    wire.extend(protocol::encode(&ServerMsg::ScreenUpdate(screen_update)).unwrap());

    // Decode the sequence as the client would
    let mut offset = 0;
    let mut messages = Vec::new();
    while offset < wire.len() {
        let (data, consumed) = protocol::codec::decode_frame(&wire[offset..])
            .unwrap()
            .expect("should decode complete frame");
        let msg: ServerMsg = protocol::codec::decode(data).unwrap();
        messages.push(msg);
        offset += consumed;
    }

    // Verify message order: Connected → History → ScreenUpdate
    assert!(matches!(messages[0], ServerMsg::Connected { .. }));
    assert!(matches!(messages[1], ServerMsg::History(_)));
    assert!(matches!(messages[2], ServerMsg::ScreenUpdate(_)));

    // Simulate client stdout
    let mut stdout = Vec::new();
    for msg in &messages {
        match msg {
            ServerMsg::History(lines) => {
                for line in lines {
                    stdout.extend_from_slice(line);
                    stdout.extend_from_slice(b"\r\n");
                }
            }
            ServerMsg::ScreenUpdate(data) => {
                stdout.extend_from_slice(data);
            }
            _ => {}
        }
    }

    let text = String::from_utf8_lossy(&stdout);
    // History lines present
    assert!(text.contains("L01"), "L01 should be in output");
    assert!(text.contains("L03"), "L03 should be in output");
    // Screen content present after clear
    let clear_pos = text.find("\x1b[2J").expect("screen clear");
    let after_clear = &text[clear_pos..];
    assert!(after_clear.contains("L04"), "L04 should be on screen");
    assert!(after_clear.contains("L06"), "L06 should be on screen");
}

#[test]
fn e2e_resize_between_reattach_cycles() {
    // Simulate: create session → produce output → detach → resize → reattach
    let mut screen = Screen::new(10, 5, 100);
    write_labeled_lines(&mut screen, 10);
    let _ = screen.take_pending_scrollback();

    // === First reattach (at 5 rows) ===
    let (hist1, update1) = simulate_reattach(&screen);
    assert_eq!(hist1.len(), 5, "first reattach: 5 history lines");

    // Verify first reattach output is correct
    let mut stdout1 = client_write_history(&hist1);
    stdout1.extend_from_slice(&update1);
    let text1 = String::from_utf8_lossy(&stdout1);
    assert!(text1.contains("L01"), "first reattach should have L01 in history");

    // === Simulate detach, then resize to 3 rows ===
    screen.resize(10, 3);
    let _ = screen.take_pending_scrollback(); // drain stale

    // === Second reattach (at 3 rows) ===
    let (hist2, update2) = simulate_reattach(&screen);

    // Build client output
    let mut stdout2 = client_write_history(&hist2);
    stdout2.extend_from_slice(&update2);
    let text2 = String::from_utf8_lossy(&stdout2);

    // Scrollback should have grown (screen shrank, but only bottom rows were lost)
    assert!(
        hist2.len() >= hist1.len(),
        "shrink should not reduce scrollback, before={}, after={}",
        hist1.len(),
        hist2.len()
    );

    // Verify no duplication between history and screen content after clear
    let clear_pos2 = text2.find("\x1b[2J").expect("screen clear in second reattach");
    let history_portion = &text2[..clear_pos2];
    let screen_portion = &text2[clear_pos2..];

    // Find which labels are in history vs screen — they should not overlap
    for i in 1..=10 {
        let label = format!("L{:02}", i);
        let in_hist = history_portion.contains(&label);
        let in_screen = screen_portion.contains(&label);
        assert!(
            !(in_hist && in_screen),
            "'{}' appears in both history and screen portions",
            label
        );
    }

    // === Simulate detach, then resize back to 8 rows ===
    screen.resize(10, 8);
    let _ = screen.take_pending_scrollback();

    // === Third reattach (at 8 rows) ===
    let (hist3, update3) = simulate_reattach(&screen);

    let mut stdout3 = client_write_history(&hist3);
    stdout3.extend_from_slice(&update3);
    let _text3 = String::from_utf8_lossy(&stdout3);

    // Scrollback should have shrunk (lines restored to screen)
    assert!(
        hist3.len() < hist2.len(),
        "expand should reduce scrollback, before={}, after={}",
        hist2.len(),
        hist3.len()
    );

    // Flush newlines should match new row count if history is present
    if !hist3.is_empty() {
        let cursor_prefix = format!("\x1b[{};1H", 8);
        assert!(
            String::from_utf8_lossy(&update3).contains(&cursor_prefix),
            "flush should use new row count (8)"
        );
    }

    // Content should still be coherent
    let all = all_content(&screen);
    let unique: std::collections::HashSet<&String> = all.iter().collect();
    assert_eq!(
        unique.len(),
        all.len(),
        "no duplicates after multiple resize+reattach cycles: {:?}",
        all
    );
}

#[test]
fn e2e_scrollback_during_session_then_reattach() {
    // Simulate active scrollback (render_with_scrollback) then reattach
    let mut screen = Screen::new(10, 3, 100);
    write_labeled_lines(&mut screen, 5);

    // Take pending scrollback as the pty_to_client loop would
    let pending = screen.take_pending_scrollback();
    assert_eq!(pending.len(), 2, "2 lines scrolled off");

    // Render with scrollback (atomic update as server does)
    let mut cache = RenderCache::new();
    let atomic_update = screen.render_with_scrollback(&pending, &mut cache);
    let atomic_text = String::from_utf8_lossy(&atomic_update);

    // Scrollback lines should appear before screen clear
    assert!(atomic_text.contains("L01"), "scrollback should contain L01");
    let clear_pos = atomic_text
        .find("\x1b[2J")
        .expect("atomic update should have screen clear");
    let l01_pos = atomic_text
        .find("L01")
        .expect("L01 should be in output");
    assert!(
        l01_pos < clear_pos,
        "scrollback content should precede screen clear"
    );

    // Now simulate reattach
    let (hist, screen_update) = simulate_reattach(&screen);

    // The scrollback lines should be in history
    let hist_texts: Vec<String> = hist.iter().map(|b| strip_ansi(b)).collect();
    assert!(
        hist_texts.iter().any(|t| t.contains("L01")),
        "L01 should be in reattach history: {:?}",
        hist_texts
    );

    // Full client output should be coherent
    let mut stdout = client_write_history(&hist);
    stdout.extend_from_slice(&screen_update);
    let text = String::from_utf8_lossy(&stdout);
    assert!(text.contains("L01"), "reattach should include L01");
    assert!(text.contains("L05"), "reattach should include L05");
}

#[test]
fn e2e_reattach_wide_terminal_to_narrow() {
    let mut screen = Screen::new(40, 5, 100);
    // Write long lines that will be truncated on resize
    for i in 1..=8 {
        let line = format!("LINE{:02}--padding-to-fill-wide-terminal---", i);
        if i < 8 {
            screen.process(format!("{}\r\n", line).as_bytes());
        } else {
            screen.process(line.as_bytes());
        }
    }
    let _ = screen.take_pending_scrollback();

    // First reattach at 40 cols
    let (hist_wide, _) = simulate_reattach(&screen);
    let hist_wide_texts: Vec<String> = hist_wide.iter().map(|b| strip_ansi(b)).collect();

    // Resize to narrow terminal
    screen.resize(10, 5);
    let _ = screen.take_pending_scrollback();

    // Second reattach at 10 cols — scrollback should still have old-width content
    let (hist_narrow, screen_update) = simulate_reattach(&screen);
    let hist_narrow_texts: Vec<String> = hist_narrow.iter().map(|b| strip_ansi(b)).collect();

    // Scrollback lines from before resize keep their original width
    for line in &hist_narrow_texts[..hist_wide_texts.len().min(hist_narrow_texts.len())] {
        assert!(
            line.contains("LINE"),
            "old scrollback line should still have content: '{}'",
            line
        );
    }

    // Screen update should be valid
    let update_text = String::from_utf8_lossy(&screen_update);
    assert!(
        update_text.contains("\x1b[?2026h"),
        "screen update should have sync begin"
    );
    assert!(
        update_text.contains("\x1b[?2026l"),
        "screen update should have sync end"
    );
}