fastqc-rust 1.1.0

A Rust rewrite of FastQC - a quality control tool for high throughput sequence data
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
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
//! Live terminal progress reporting, drawn in place:
//!
//! ```text
//! FastQC-Rust v1.1.0
//!
//! Failed to process notes.txt: ID line didn't start with '@' at line 1
//!   sample_1.fastq.gz  ⠹ ━━━━━━━━━━━━━━━━━━━━╸━━━━━━━  72%  2.1M reads     4s
//!   sample_2.fastq.gz  ✔ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%  3.0M reads     6s
//!
//!   ┌───────────────────────────────────┬────────────────┬────────────────┐
//!   │ Measure                           │ sample_1.fast… │ sample_2.fast… │
//!   ├───────────────────────────────────┼────────────────┼────────────────┤
//!   │ File type                         │ Conventional … │ Conventional … │
//!   │ ...                               │ ...            │ ...            │
//!   └───────────────────────────────────┴────────────────┴────────────────┘
//! Complete. Analysed 2 files in 00:06
//! ```
//!
//! The bars, the table and the closing summary are a redrawn region pinned to
//! the bottom of the terminal. The name and version, and any warning or error,
//! are ordinary log lines that scroll up above it.
//!
//! The display adapts to the size of the run:
//!
//! * **1-10 files** get a progress bar each, in the order given on the command
//!   line, showing that file's own progress.
//! * **More than 10 files** collapse to a single bar counting completed files,
//!   because a screenful of bars is worse than no bars at all.
//! * **A live statistics table** is drawn underneath whenever the terminal is
//!   wide enough for every column to be readable — so a wide terminal gets it
//!   for more files, and a narrow one does without. Its columns are the files
//!   and its rows the Basic Statistics measures from the top of the HTML
//!   report. Cells start as `-` and fill in as the analysis runs; the final
//!   values are exactly those in the report, because both are rendered from the
//!   same counters (see
//!   [`crate::modules::basic_stats::BasicStatsCounters::rows`]).
//!
//! # Animation and colour
//!
//! These are two independent switches, each auto-detected and each overridable
//! through the environment — there are no command-line flags for them.
//! `--quiet` beats both and says nothing but warnings and errors.
//!
//! **Animation** (`FASTQC_PROGRESS=auto|always|never`) — the default `auto`
//! draws the display only for an interactive stderr. When stderr is a pipe, a
//! log file or a workflow engine's capture, or when `TERM` says the terminal
//! cannot handle a redrawn region (`dumb`, or unset on Unix), a redrawn region
//! would be noise or outright corruption, so the reporter degrades to one plain
//! line per file at start and finish. `always` draws it regardless — for
//! recording a demo, or feeding a consumer that re-renders the stream — by
//! handing indicatif the terminal directly instead of its self-hiding stderr
//! draw target, sizing itself from `COLUMNS`/`LINES` since there is no terminal
//! to measure. `never` always takes the plain path.
//!
//! **Colour** follows [`console::colors_enabled_stderr`], which implements the
//! usual conventions: the [clicolors spec](https://bixense.com/clicolors/)
//! `CLICOLOR=0` disables colour and `CLICOLOR_FORCE=1` forces it on even for a
//! pipe, and `TERM=dumb` disables it. A non-empty `NO_COLOR` disables it over
//! all of those, which console does not do on its own.
//! indicatif's template styling reads the same function, so one signal covers
//! this module's styling and the bars alike.
//!
//! Because the two are independent: colour off still draws the bars, just
//! without escape codes; and the plain fallback still colours its lines when
//! colour is forced on, which is what a CI log viewer wants — it renders escape
//! sequences happily while not being a terminal.
//!
//! # Log lines
//!
//! Anything printed while the display is up has to go through [`log_line`], or
//! [`ProgressReporter::error`]. Writing to stderr directly would land in the
//! middle of the display and be erased by the next frame. A warning raised from
//! an inner analysis loop pairs [`log_line`] with an [`OncePerRun`] latch, so
//! the run says it once instead of once per base.
//!
//! Those lines are written *above* the redrawn region and scroll up as ordinary
//! terminal output, which is what rich, tqdm, indicatif, cargo and Nextflow all
//! do. The log is the permanent record and has to be able to grow without
//! bound, and the terminal's scrollback is the only place unbounded output can
//! go. The version banner is printed by [`ProgressPlan::new`] before the run
//! does anything else, so everything the run has to say appears beneath it in
//! the order it happened.
//!
//! The exception is the closing `Complete. Analysed N files in mm:ss`, which is
//! the last line of the redrawn region so that it always lands below the bars
//! and the table. `--quiet` suppresses it along with everything else.

use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};

use console::{style, Term};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle, TermLike};

use crate::modules::basic_stats::{BasicStatsCounters, LiveStats};

/// Environment variable selecting the progress display. `FASTQC_`-prefixed so
/// that it cannot collide with anything in the environment it is read from.
pub const PROGRESS_ENV: &str = "FASTQC_PROGRESS";

/// A tri-state switch for behaviour that is normally auto-detected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum When {
    /// Decide from the environment (default).
    #[default]
    Auto,
    /// Force on, whatever the environment looks like.
    Always,
    /// Force off.
    Never,
}

impl When {
    /// Parse a `FASTQC_PROGRESS`-style value. Unrecognised values fall back to
    /// `Auto` rather than failing a run over a display preference.
    fn parse(value: &str) -> Self {
        match value.trim().to_ascii_lowercase().as_str() {
            "always" | "force" | "1" | "yes" | "true" | "on" => When::Always,
            "never" | "none" | "0" | "no" | "false" | "off" => When::Never,
            _ => When::Auto,
        }
    }

    fn from_env() -> Self {
        std::env::var(PROGRESS_ENV)
            .map(|v| Self::parse(&v))
            .unwrap_or_default()
    }
}

/// Above this many files, per-file bars are replaced by a single bar counting
/// completed files.
const MAX_FILE_BARS: usize = 10;

/// Widest a value column grows before the extra room is left as margin.
const MAX_VALUE_WIDTH: usize = 28;

/// Narrowest a statistics-table value column may be and still be worth reading.
/// Whether the table is shown at all is decided by whether every column can
/// have at least this much room (see [`layout`]), so a wide terminal shows
/// the table for more files and a narrow one drops it sooner.
const MIN_VALUE_WIDTH: usize = 16;

/// How often the live statistics table is re-rendered.
const TABLE_REFRESH: Duration = Duration::from_millis(150);

/// Redraw rate for a forced display, matching indicatif's own default for its
/// stderr draw target.
const DRAW_RATE_HZ: u8 = 20;

/// Spinner animation period for the running bars.
const SPINNER_TICK: Duration = Duration::from_millis(90);

/// Fallback terminal size when it cannot be measured and the environment does
/// not say (`FASTQC_PROGRESS=always` over a pipe, for instance).
const DEFAULT_TERM_WIDTH: usize = 100;
const DEFAULT_TERM_HEIGHT: u16 = 24;

/// Longest a file name may be before it is truncated in a bar label.
const MAX_NAME_WIDTH: usize = 30;

/// Progress is tracked in permille rather than percent so the bars move
/// smoothly rather than in 1% steps.
const SCALE: u64 = 1000;

/// Where log lines go while a display is on screen.
///
/// Lines are written above the redrawn region and scroll up as ordinary
/// terminal output, which is the convention for this kind of display (rich,
/// tqdm, indicatif, cargo, Nextflow all do the same). The log is the permanent
/// record and has to be able to grow without bound; the terminal's scrollback
/// is the only place unbounded output can go, and it is below the display that
/// there is no room.
struct LogSink {
    multi: MultiProgress,
    /// `\r\n` when the display has been forced onto something that is not a
    /// terminal. A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR);
    /// nothing does that for a pipe, so a bare newline would leave the cursor
    /// parked in this line's column and the next frame would start there.
    line_ending: &'static str,
    /// First line of the redrawn region, blank once anything has been logged so
    /// the bars are not flush against the messages above them. It renders as
    /// nothing while empty, which is what keeps it out of the way on the usual
    /// run that logs nothing at all.
    padding: ProgressBar,
}

impl LogSink {
    fn print(&self, message: &str) {
        self.padding.set_message(" ");
        // Erase the region, write the line as ordinary scrollback, and redraw
        // the display beneath it, all under indicatif's draw lock so no other
        // thread's frame can land between the erase and the line.
        self.multi
            .suspend(|| eprint!("{}{}", message, self.line_ending));
    }
}

/// The log sink of the display currently drawing to stderr, if there is one.
///
/// Code deep in the analysis (a module noticing bad data, a reader hitting an
/// odd record) has no handle on the reporter, but its warnings must not be
/// written straight to stderr while a redrawn region is on screen — they would
/// land in the middle of the bars and be overwritten by the next frame.
static ACTIVE_LOG: Mutex<Option<Arc<LogSink>>> = Mutex::new(None);

/// Emit a line above the progress display, wherever it is called from. Falls
/// back to plain stderr when no display is active.
pub fn log_line(message: &str) {
    let active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()).clone();
    match active {
        Some(sink) => sink.print(message),
        None => eprintln!("{}", message),
    }
}

/// Which run is in progress. Bumped by [`ProgressPlan::new`], and compared
/// against by [`OncePerRun`] to know whether it has already spoken.
static RUN: AtomicU64 = AtomicU64::new(1);

/// A latch for a warning raised from an innermost analysis loop, where the same
/// message can be produced millions of times — once per bad base, once per
/// unreadable read.
///
/// Declared as a `static` beside the code that raises the warning, so asking
/// whether to speak is one relaxed atomic: no formatting, no allocation and no
/// lock on the overwhelmingly common repeat. That matters because these sit in
/// the hot loop — building the message unconditionally and then discarding it
/// cost ~26% of the analysis on a file whose every base tripped the warning.
///
/// The latch is per *run*, not per process: it resets when the next
/// [`ProgressPlan`] is created, so an embedder driving the library twice hears
/// the warning both times. Within a run it fires once however many files are
/// being analysed in parallel, which is the point — the warning is about the
/// data, and one copy of it is the useful amount.
pub struct OncePerRun(AtomicU64);

impl Default for OncePerRun {
    fn default() -> Self {
        Self::new()
    }
}

impl OncePerRun {
    pub const fn new() -> Self {
        OncePerRun(AtomicU64::new(0))
    }

    /// True for exactly one caller per run. Racing threads all swap in the
    /// current run, and only the one that displaced an older value speaks.
    /// The plain load first keeps repeats off the cache line's write path.
    pub fn should_say(&self) -> bool {
        let run = RUN.load(Ordering::Relaxed);
        self.0.load(Ordering::Relaxed) != run && self.0.swap(run, Ordering::Relaxed) != run
    }

    /// [`log_line`] the warning `message` builds, the first time this run.
    /// Out of line so the hot loop it is raised from stays tight.
    #[cold]
    #[inline(never)]
    pub fn log(&self, message: impl FnOnce() -> String) {
        if self.should_say() {
            log_line(&message());
        }
    }
}

/// The terminal progress display for a whole run.
///
/// Cheap to share across the rayon workers: every method is `&self` and
/// no-ops when the display is disabled.
pub struct ProgressReporter {
    mode: Mode,
    /// When the run started, for the closing summary.
    started: Instant,
}

enum Mode {
    /// `--quiet`: say nothing at all.
    Silent,
    /// No redrawn display: one plain line per file at start and finish. Still
    /// colour-aware, because a CI log viewer renders escape sequences happily
    /// even though it is not a terminal.
    Plain,
    /// A terminal: live bars, and optionally a live statistics table.
    Live(Box<Live>),
}

struct Live {
    bars: Bars,
    table: Option<Arc<Table>>,
    /// The closing summary, drawn as the last line of the region so it always
    /// lands below the bars and the table. Empty until the run finishes.
    summary: ProgressBar,
    /// Blank line kept at the bottom of the redrawn region.
    trailer: ProgressBar,
    /// Where log lines go: above the display, as ordinary scrollback.
    log: Arc<LogSink>,
    ticker: Mutex<Option<JoinHandle<()>>>,
    stop: Arc<AtomicBool>,
}

/// Cheap to clone: the ticker thread holds its own handle.
#[derive(Clone)]
enum Bars {
    /// One bar per file, indexed by file group.
    PerFile(Arc<Vec<FileBar>>),
    /// A single bar counting completed files.
    Aggregate(ProgressBar),
}

/// One file's bar, plus whether its analysis has actually begun.
///
/// The flag matters because indicatif starts a bar's clock when the bar is
/// *created*, and all the bars are created together before any file is opened.
/// A run with more files than parallel slots would otherwise show a queued file
/// counting up the time it spent waiting, and then report that as how long it
/// took. So the clock is reset when the file starts, and until then the bar is
/// left alone entirely — not even ticked, so its spinner does not animate as
/// though something were happening.
struct FileBar {
    bar: ProgressBar,
    started: AtomicBool,
}

impl Bars {
    /// The bars whose spinner should be advancing: the ones whose file is
    /// actually being worked on. A file still queued behind another has nothing
    /// happening, and a spinning spinner would say otherwise.
    fn spinners(&self) -> Box<dyn Iterator<Item = &ProgressBar> + '_> {
        match self {
            Bars::PerFile(bars) => Box::new(
                bars.iter()
                    .filter(|file| file.started.load(Ordering::Relaxed))
                    .map(|file| &file.bar),
            ),
            Bars::Aggregate(bar) => Box::new(std::iter::once(bar)),
        }
    }
}

/// Which of the three display modes a run should use.
///
/// Split out from [`ProgressReporter::new`] so the decision can be tested
/// without a terminal or environment fiddling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModeChoice {
    Silent,
    Plain,
    /// `bypass_detection` when the display is going somewhere indicatif would
    /// refuse to draw to, which is the one thing `Live::new` needs to know: it
    /// then has to drive the terminal itself rather than through indicatif's
    /// self-hiding stderr target. That is a fact about the output, not about
    /// what was asked for — `FASTQC_PROGRESS=always` in a real terminal is an
    /// ordinary live display.
    Live {
        bypass_detection: bool,
    },
}

/// Decide how to report progress.
///
/// `--quiet` wins over everything: it is the stronger statement, so
/// `--quiet FASTQC_PROGRESS=always` is still silent. Otherwise
/// `FASTQC_PROGRESS` decides if it was given explicitly, and only `auto`
/// consults the environment.
///
/// Auto-detection needs a terminal the display can redraw in place: stderr must
/// be a tty *and* `TERM` must describe a terminal that can do more than accept
/// plain text. `dumb` (and, on Unix, an unset `TERM`) fails that second test —
/// indicatif hides its bars in exactly that case, so without the check a run
/// would print a banner and then go completely silent.
fn choose_mode(
    quiet: bool,
    progress: When,
    stderr_is_terminal: bool,
    dumb_terminal: bool,
) -> ModeChoice {
    if quiet {
        return ModeChoice::Silent;
    }
    // The same condition indicatif's stderr draw target hides itself on, so
    // when it holds there is nothing to bypass and `always` costs nothing.
    let drawable = stderr_is_terminal && !dumb_terminal;
    match progress {
        When::Always => ModeChoice::Live {
            bypass_detection: !drawable,
        },
        When::Never => ModeChoice::Plain,
        When::Auto if drawable => ModeChoice::Live {
            bypass_detection: false,
        },
        When::Auto => ModeChoice::Plain,
    }
}

/// The mode this process would use, from `--quiet` and the environment.
///
/// Called once, by [`ProgressPlan::new`], which stores the answer so both
/// phases of startup act on the same decision. Split from [`choose_mode`] so
/// that the rule itself can be tested without a terminal or the environment.
fn current_choice(quiet: bool) -> ModeChoice {
    choose_mode(
        quiet,
        When::from_env(),
        // console's own tty probe, not `std::io::IsTerminal`: indicatif decides
        // whether to hide its bars with `console::Term::is_term`, and the two
        // disagree on an MSYS pty, where console recognises a terminal that
        // `IsTerminal` does not.
        Term::stderr().is_term(),
        console::is_dumb(),
    )
}

/// A decided-but-not-yet-drawn display, and the first half of starting a run.
///
/// The display cannot be built until the input files have been validated and
/// grouped, because it needs one bar per group — but the banner has to be
/// printed *before* that work, or the messages validation emits land above it
/// and the "everything the run says appears beneath the banner" property is
/// quietly false. So the decision and the banner happen here, at the very top
/// of the run, and [`start`](Self::start) turns the plan into a reporter once
/// the names are known.
pub struct ProgressPlan {
    choice: ModeChoice,
    started: Instant,
}

impl ProgressPlan {
    /// Decide how this run will report, and announce it. Call this first:
    /// the clock it starts is the one the closing summary reports.
    pub fn new(quiet: bool) -> Self {
        // console lets CLICOLOR_FORCE override NO_COLOR; the NO_COLOR spec
        // says it wins over everything.
        if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
            console::set_colors_enabled_stderr(false);
        }
        let choice = current_choice(quiet);
        // "Once per run" for [`OncePerRun`] means once per plan.
        RUN.fetch_add(1, Ordering::Relaxed);
        if let ModeChoice::Live { bypass_detection } = choice {
            // Coloured after the logo: the name in its blue, the `-Rust`
            // suffix in its red, the version dim. Written straight to stderr
            // because nothing has been drawn yet — there is no region to clear
            // and no padding to add.
            let line_ending = line_ending(bypass_detection);
            eprint!(
                "{}{} {}{line_ending}{line_ending}",
                paint("FastQC", |s| s.color256(LOGO_BLUE).bold()),
                paint("-Rust", |s| s.color256(LOGO_RED).bold()),
                paint(&format!("v{}", crate::RUST_VERSION), |s| s.dim()),
            );
        }
        ProgressPlan {
            choice,
            started: Instant::now(),
        }
    }

    /// Draw the display for `names` (the file group display names, in
    /// command-line order).
    pub fn start(self, names: &[String]) -> ProgressReporter {
        let mode = match self.choice {
            ModeChoice::Silent => Mode::Silent,
            ModeChoice::Plain => Mode::Plain,
            ModeChoice::Live { bypass_detection } => {
                Mode::Live(Box::new(Live::new(names, bypass_detection)))
            }
        };
        ProgressReporter {
            mode,
            started: self.started,
        }
    }
}

/// A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR); nothing does
/// that for a pipe, so a bare newline would leave the cursor parked in this
/// line's column and the next frame would start there.
fn line_ending(bypass_detection: bool) -> &'static str {
    if bypass_detection {
        "\r\n"
    } else {
        "\n"
    }
}

impl ProgressReporter {
    /// A reporter that displays nothing. Used by tests and by callers of the
    /// library API that drive the analysis themselves.
    pub fn hidden() -> Self {
        ProgressReporter {
            mode: Mode::Silent,
            started: Instant::now(),
        }
    }

    /// A handle scoped to one file group, for the code that actually runs the
    /// analysis.
    pub fn file(&self, index: usize) -> FileProgress<'_> {
        FileProgress {
            reporter: self,
            index,
        }
    }

    /// Print an error line above the display. Shown even under `--quiet`.
    ///
    /// Routed through [`log_line`] rather than matched on the mode: the two
    /// would say the same thing, since a log sink is registered exactly while
    /// a display is up.
    pub fn error(&self, message: &str) {
        log_line(&paint_error(message));
    }

    /// Tear the display down once every file is done, leaving the final state
    /// on screen, and report what the run got through.
    ///
    /// `analysed` is the number of file groups that completed successfully;
    /// anything that failed has already been reported as an error line.
    /// `failed` turns the closing "Complete." red.
    pub fn finish(&self, analysed: usize, failed: bool) {
        let summary = format!(
            "Analysed {} {} in {}",
            analysed,
            if analysed == 1 { "file" } else { "files" },
            clock_duration(self.started.elapsed()),
        );
        let complete = paint("Complete.", |s| {
            if failed { s.red() } else { s.green() }.bold()
        });
        match &self.mode {
            // --quiet stays quiet: the run said nothing, so it ends saying
            // nothing.
            Mode::Silent => {}
            Mode::Plain => eprintln!("{} {}", complete, summary),
            Mode::Live(live) => {
                // The last line of the redrawn region, so it lands below the
                // bars and the table rather than scrolling past above them.
                live.summary
                    .set_message(format!("{} {}", complete, paint(&summary, |s| s.dim())));
                live.finish(analysed);
            }
        }
    }
}

/// A progress handle for a single file group.
#[derive(Clone, Copy)]
pub struct FileProgress<'a> {
    reporter: &'a ProgressReporter,
    index: usize,
}

impl FileProgress<'_> {
    /// The live statistics sink for this file, if the table is being shown.
    /// Attached to the file's BasicStats module so it can publish partial
    /// results as it works.
    pub fn live_stats(&self) -> Option<Arc<LiveStats>> {
        match &self.reporter.mode {
            Mode::Live(live) => live
                .table
                .as_ref()
                .and_then(|t| t.columns.get(self.index))
                .map(|c| Arc::clone(&c.live)),
            _ => None,
        }
    }

    /// Announce that analysis of this file has begun.
    pub fn start(&self, name: &str) {
        match &self.reporter.mode {
            Mode::Silent => {}
            Mode::Plain => eprintln!("Started analysis of {}", paint(name, |s| s.bold())),
            Mode::Live(live) => live.start(self.index),
        }
    }

    /// Report how far through the file the reader is. `reads` is the number of
    /// records handed to the modules so far.
    ///
    /// `percent` is a closure rather than a value because
    /// `SequenceFile::percent_complete` costs a seek on the input file, and
    /// there is often no bar for it to move — under `--quiet`, without a
    /// terminal, or when many files share a single completion-counting bar.
    /// Leaving that decision here keeps the reader from having to ask.
    pub fn update(&self, reads: u64, percent: impl FnOnce() -> f64) {
        if let Mode::Live(live) = &self.reporter.mode {
            live.progress(self.index, reads, percent);
        }
    }

    /// Note that the file has been read and the run has moved on to another
    /// phase (rendering charts, writing the report).
    pub fn stage(&self, stage: &str) {
        if let Mode::Live(live) = &self.reporter.mode {
            live.stage(self.index, stage);
        }
    }

    /// Mark the file finished.
    pub fn finish(&self, name: &str, reads: u64) {
        match &self.reporter.mode {
            Mode::Silent => {}
            Mode::Plain => eprintln!("Analysis complete for {}", paint(name, |s| s.bold())),
            Mode::Live(live) => live.finish_file(self.index, reads),
        }
    }

    /// Mark the file failed.
    pub fn fail(&self) {
        if let Mode::Live(live) = &self.reporter.mode {
            live.fail_file(self.index);
        }
    }
}

impl Live {
    /// `bypass_detection` means stderr is not something indicatif will draw a
    /// redrawn region to, and the display has been forced on anyway.
    fn new(names: &[String], bypass_detection: bool) -> Self {
        // The default stderr draw target hides itself when stderr is not an
        // interactive terminal. `FASTQC_PROGRESS=always` asks for the display
        // anyway, so bypass that check by handing indicatif the terminal
        // directly: `term_like` performs no detection of its own. It also
        // applies no rate limiting unless one is given, which would redraw the
        // whole display on every single position update, so pass the same
        // refresh rate `ProgressDrawTarget::stderr` uses.
        let multi = if bypass_detection {
            MultiProgress::with_draw_target(ProgressDrawTarget::term_like_with_hz(
                Box::new(ForcedTerm::new()),
                DRAW_RATE_HZ,
            ))
        } else {
            MultiProgress::new()
        };

        let log = Arc::new(LogSink {
            multi: multi.clone(),
            line_ending: line_ending(bypass_detection),
            // First line of the region, so the blank it grows lands between the
            // messages and the bars.
            padding: static_line(&multi),
        });
        *ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&log));

        let label_width = names
            .iter()
            .map(|n| console::measure_text_width(n))
            .max()
            .unwrap_or(0)
            .min(MAX_NAME_WIDTH)
            .max("FastQ files".len());

        let bars = if names.len() > MAX_FILE_BARS {
            // Too many files for a bar each: count completed files instead.
            let bar = multi.add(ProgressBar::new(names.len() as u64));
            bar.set_style(aggregate_style());
            bar.set_prefix(pad_cell(&paint("FastQ files", |s| s.bold()), label_width));
            Bars::Aggregate(bar)
        } else {
            let running = running_style();
            let bars = names
                .iter()
                .map(|name| {
                    let bar = multi.add(ProgressBar::new(SCALE));
                    bar.set_style(running.clone());
                    bar.set_prefix(pad_cell(&paint(name, |s| s.bold()), label_width));
                    bar.set_message("waiting");
                    bar.tick();
                    FileBar {
                        bar,
                        started: AtomicBool::new(false),
                    }
                })
                .collect();
            Bars::PerFile(Arc::new(bars))
        };

        // The table decides for itself, on every refresh, whether the terminal
        // is currently wide enough to give every column room to be read, so it
        // appears and disappears as the window is resized. It is built here
        // whenever there is anything at all to tabulate.
        let table = (!names.is_empty()).then(|| Arc::new(Table::new(&multi, names)));

        // The closing summary is the last line of the region, so it always
        // lands below the bars and the table however the run went. It renders
        // as nothing until it has a message.
        let summary = static_line(&multi);

        // A blank line below everything, so the shell prompt does not land
        // flush against the display.
        let trailer = static_line(&multi);
        trailer.set_message(" ");

        let live = Live {
            bars,
            table,
            summary,
            trailer,
            log,
            ticker: Mutex::new(None),
            stop: Arc::new(AtomicBool::new(false)),
        };
        live.start_ticker();
        live
    }

    /// Animate the display from a single background thread: the analysis threads
    /// only ever publish counters and positions, they never render.
    ///
    /// indicatif's own `enable_steady_tick` spawns a thread per bar, which for
    /// a ten-file run means ten threads competing for the same draw lock purely
    /// to advance a spinner. One thread does both jobs.
    fn start_ticker(&self) {
        let table = self.table.as_ref().map(Arc::clone);
        let bars = self.bars.clone();
        let stop = Arc::clone(&self.stop);
        let handle = std::thread::Builder::new()
            .name("fastqc-progress".into())
            .spawn(move || {
                // The table is re-rendered more slowly than the spinners are
                // advanced, on its own deadline rather than a second thread.
                let mut due = Instant::now();
                while !stop.load(Ordering::Relaxed) {
                    for bar in bars.spinners() {
                        if !bar.is_finished() {
                            bar.tick();
                        }
                    }
                    let now = Instant::now();
                    if now >= due {
                        due = now + TABLE_REFRESH;
                        if let Some(table) = &table {
                            table.refresh();
                        }
                    }
                    std::thread::sleep(SPINNER_TICK);
                }
                // One last render so the table shows the finished values.
                if let Some(table) = &table {
                    table.refresh();
                }
            });
        if let Ok(handle) = handle {
            *self.ticker.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
        }
    }

    fn bar(&self, index: usize) -> Option<&ProgressBar> {
        match &self.bars {
            Bars::PerFile(bars) => bars.get(index).map(|file| &file.bar),
            Bars::Aggregate(_) => None,
        }
    }

    fn start(&self, index: usize) {
        let Bars::PerFile(bars) = &self.bars else {
            return;
        };
        let Some(file) = bars.get(index) else {
            return;
        };
        file.started.store(true, Ordering::Relaxed);
        // The bar was created with every other bar, before any file was opened,
        // and indicatif has been counting since. Restart the clock so the time
        // this bar reports is the time this file took.
        file.bar.reset_elapsed();
        file.bar.set_message("reading");
    }

    /// Both halves of this are guarded by a comparison against the bar's
    /// current state, because both `set_position` and `set_message` reach
    /// indicatif's redraw — which takes the global draw lock and re-formats the
    /// line — while `position` and `message` only take that one bar's lock.
    /// This runs once per thousand reads on every file at once, and most calls
    /// change nothing: the bar has only [`SCALE`] distinct positions, and
    /// `human_count` is coarse enough that past a million reads the same label
    /// is produced for a hundred consecutive updates.
    fn progress(&self, index: usize, reads: u64, percent: impl FnOnce() -> f64) {
        let Some(bar) = self.bar(index) else {
            return;
        };
        let position = (percent().clamp(0.0, 100.0) / 100.0 * SCALE as f64) as u64;
        if bar.position() != position {
            bar.set_position(position);
        }
        let label = format!("{} reads", human_count(reads));
        if bar.message() != label {
            bar.set_message(label);
        }
    }

    fn stage(&self, index: usize, stage: &str) {
        if let Some(bar) = self.bar(index) {
            bar.set_position(SCALE);
            bar.set_message(stage.to_string());
        }
    }

    /// Record how a file ended so the table heading can follow its bar.
    fn set_file_state(&self, index: usize, state: FileState) {
        if let Some(column) = self.table.as_ref().and_then(|t| t.columns.get(index)) {
            column.state.store(state as u8, Ordering::Relaxed);
        }
    }

    fn finish_file(&self, index: usize, reads: u64) {
        self.set_file_state(index, FileState::Analysed);
        match &self.bars {
            Bars::PerFile(bars) => {
                if let Some(file) = bars.get(index) {
                    file.bar.set_style(done_style());
                    file.bar.set_position(SCALE);
                    file.bar
                        .set_message(format!("{} reads", human_count(reads)));
                    file.bar.finish();
                }
            }
            Bars::Aggregate(bar) => bar.inc(1),
        }
    }

    fn fail_file(&self, index: usize) {
        self.set_file_state(index, FileState::Failed);
        match &self.bars {
            Bars::PerFile(bars) => {
                if let Some(file) = bars.get(index) {
                    file.bar.set_style(failed_style());
                    file.bar.set_message("failed");
                    file.bar.abandon();
                }
            }
            Bars::Aggregate(bar) => bar.inc(1),
        }
    }

    fn finish(&self, analysed: usize) {
        if let Bars::Aggregate(bar) = &self.bars {
            bar.set_style(if bar.length() == Some(analysed as u64) {
                aggregate_done_style()
            } else {
                aggregate_failed_style()
            });
            bar.finish();
        }
        self.shut_down();
        // indicatif erases any bar that is still unfinished when it is dropped,
        // so the static lines have to be explicitly finished for the completed
        // display to survive the end of the run.
        if let Some(table) = &self.table {
            table.finish();
        }
        self.log.padding.finish();
        self.summary.finish();
        self.trailer.finish();
    }

    /// Stop the redraw thread and detach this display from the log sink.
    /// Idempotent, so [`Drop`] can repeat it after [`Live::finish`].
    fn shut_down(&self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(handle) = self.ticker.lock().unwrap_or_else(|e| e.into_inner()).take() {
            let _ = handle.join();
        }
        let mut active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner());
        if active
            .as_ref()
            .is_some_and(|sink| Arc::ptr_eq(sink, &self.log))
        {
            *active = None;
        }
    }
}

/// A run that unwinds before [`Live::finish`] must not leave the ticker
/// redrawing a dead display, or log lines routed into it.
impl Drop for Live {
    fn drop(&mut self) {
        self.shut_down();
    }
}

/// Add a line of static text to the redrawn region. Implemented as a progress
/// bar that renders nothing but its message, which is how indicatif keeps a
/// block of text pinned below the bars.
fn static_line(multi: &MultiProgress) -> ProgressBar {
    let line = multi.add(ProgressBar::new(0));
    line.set_style(ProgressStyle::with_template("{msg}").expect("static template"));
    line.tick();
    line
}

/// A stderr terminal that reports a usable size even when stderr is not a tty.
///
/// `console::Term` writes its cursor-movement and clear-line escapes
/// unconditionally, so it drives the display fine over a pipe; what it cannot
/// do is measure a pipe, and it falls back to a hardcoded 80 columns. That
/// would leave `{wide_bar}` sized differently from the statistics table, which
/// measures itself. Both go through this type instead, so they agree — and
/// `COLUMNS`/`LINES` give a forced run (a recording, a CI job) a way to say how
/// wide the output should be.
#[derive(Debug)]
struct ForcedTerm {
    inner: Term,
    width: u16,
    height: u16,
}

/// How wide the display may draw, right now.
///
/// Measured on every table refresh rather than once at startup, so a window
/// resized mid-run is followed rather than ignored. `COLUMNS` covers the case
/// where stderr cannot be measured because it is not a terminal at all
/// (`FASTQC_PROGRESS=always` over a pipe).
fn measured_term_width() -> usize {
    Term::stderr()
        .size_checked()
        .map(|(_, cols)| cols)
        .or_else(|| env_dimension("COLUMNS"))
        .unwrap_or(DEFAULT_TERM_WIDTH as u16) as usize
}

impl ForcedTerm {
    fn new() -> Self {
        // Buffered, like the `Term` behind indicatif's own stderr draw target.
        // It matters: indicatif emits a frame as a run of writes and flushes at
        // the end, and with an unbuffered terminal a concurrent draw can land
        // in the middle of one, running two bar lines onto the same row.
        let inner = Term::buffered_stderr();
        let measured = inner.size_checked();
        ForcedTerm {
            width: measured_term_width() as u16,
            height: measured
                .map(|(rows, _)| rows)
                .or_else(|| env_dimension("LINES"))
                .unwrap_or(DEFAULT_TERM_HEIGHT),
            inner,
        }
    }

    /// `ESC [ n <op>`, the cursor-movement form. A zero count is a no-op
    /// rather than an escape, matching what console would emit.
    fn escape(&self, n: usize, op: char) -> std::io::Result<()> {
        if n == 0 {
            return Ok(());
        }
        self.inner.write_str(&format!("\x1b[{n}{op}"))
    }
}

fn env_dimension(name: &str) -> Option<u16> {
    std::env::var(name)
        .ok()?
        .parse::<u16>()
        .ok()
        .filter(|n| *n > 0)
}

impl TermLike for ForcedTerm {
    fn width(&self) -> u16 {
        self.width
    }

    fn height(&self) -> u16 {
        self.height
    }

    // Cursor movement is written as ANSI rather than delegated to `Term`.
    // console drives a real Windows console through the Win32 API, which
    // silently does nothing when the handle is a pipe — so delegating would
    // draw every frame and erase none, leaving one long concatenation. This
    // type is only used when the display has been forced onto something that
    // is not a terminal, where the consumer is a recorder or a log viewer that
    // interprets escapes, so emitting them is exactly right. On Unix these are
    // the same bytes `Term` would have written.
    fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
        self.escape(n, 'A')
    }

    fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
        self.escape(n, 'B')
    }

    fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
        self.escape(n, 'C')
    }

    fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
        self.escape(n, 'D')
    }

    fn write_line(&self, s: &str) -> std::io::Result<()> {
        self.inner.write_line(s)
    }

    fn write_str(&self, s: &str) -> std::io::Result<()> {
        self.inner.write_str(s)
    }

    fn clear_line(&self) -> std::io::Result<()> {
        self.inner.write_str("\r\x1b[2K")
    }

    fn flush(&self) -> std::io::Result<()> {
        self.inner.flush()
    }
}

/// The live Basic Statistics table shown underneath the progress bars.
///
/// The whole table is a *single* zero-length progress bar whose message spans
/// several lines — indicatif splits a message on newlines and accounts for
/// every line. One bar rather than one per row matters: a refresh is then a
/// single message update and so a single redraw, instead of a dozen redraws of
/// the entire display several times a second, which churns the terminal and
/// races with anything trying to print above it.
struct Table {
    line: ProgressBar,
    columns: Vec<Column>,
    /// The file names, kept so the geometry can be rebuilt at a new width.
    names: Vec<String>,
    state: Mutex<TableState>,
}

struct TableState {
    /// Terminal width [`Geometry`] was built for. A run whose window is resized
    /// rebuilds at the new width rather than drawing a table cut to the old
    /// one; `{wide_bar}` above it already reflows on its own.
    width: usize,
    /// `None` when the terminal is too narrow for the table to be worth
    /// drawing, which is also how it disappears and comes back as the window is
    /// resized across the threshold.
    geometry: Option<Geometry>,
    /// The last frame rendered, so an unchanged one can be dropped rather than
    /// pushed through indicatif and out to the terminal again.
    last: String,
}

/// Everything about the table that depends on how wide it may be, rendered once
/// per width rather than once per frame: the three borders, the styled vertical
/// rule, the heading and measure label cells, and each file's heading in each
/// of its three states.
struct Geometry {
    value_width: usize,
    top: String,
    divider: String,
    bottom: String,
    pipe: String,
    heading_label: String,
    /// One per measure, in report order — so this also fixes the row count.
    row_labels: Vec<String>,
    /// Per column, the heading pre-rendered in each state indexed by
    /// [`FileState`]: the colour is the only thing that varies, and re-styling
    /// it per frame would be pure waste.
    headings: Vec<[String; 3]>,
}

struct Column {
    live: Arc<LiveStats>,
    /// Drives the heading colour, kept in step with the file's bar.
    state: AtomicU8,
}

/// How a file is doing, for colouring its column heading the same way its
/// progress bar is coloured. The discriminant indexes [`Column::headings`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileState {
    /// Waiting or being read.
    Running = 0,
    Analysed = 1,
    Failed = 2,
}

impl Geometry {
    /// Lay a table of `names` out for a terminal of `term_width`, or `None`
    /// when it is too narrow to be worth drawing — [`layout`] decides both.
    fn new(names: &[String], term_width: usize) -> Option<Self> {
        let (label_width, value_width) = layout(names.len(), term_width)?;

        let rule = |left: char, mid: char, right: char| {
            let mut s = String::from("  ");
            s.push(left);
            s.push_str(&"─".repeat(label_width + 2));
            for _ in names {
                s.push(mid);
                s.push_str(&"─".repeat(value_width + 2));
            }
            s.push(right);
            paint(&s, |st| st.dim())
        };

        Some(Geometry {
            value_width,
            top: rule('┌', '┬', '┐'),
            divider: rule('├', '┼', '┤'),
            bottom: rule('└', '┴', '┘'),
            pipe: paint("│", |s| s.dim()),
            heading_label: pad_cell(&paint("Measure", |s| s.dim()), label_width),
            row_labels: BasicStatsCounters::MEASURES
                .iter()
                .map(|m| pad_cell(m, label_width))
                .collect(),
            headings: names
                .iter()
                // The heading tracks the file's bar: accent while it is
                // running, green once analysed, red if it failed.
                .map(|name| {
                    [
                        pad_cell(&paint(name, |s| s.cyan().bold()), value_width),
                        pad_cell(&paint(name, |s| s.green().bold()), value_width),
                        pad_cell(&paint(name, |s| s.red().bold()), value_width),
                    ]
                })
                .collect(),
        })
    }

    /// A content line, from cells that are already styled and padded.
    fn row<'a>(&self, label_cell: &str, cells: impl Iterator<Item = &'a str>) -> String {
        let mut s = String::from("  ");
        s.push_str(&self.pipe);
        s.push(' ');
        s.push_str(label_cell);
        s.push(' ');
        for cell in cells {
            s.push_str(&self.pipe);
            s.push(' ');
            s.push_str(cell);
            s.push(' ');
        }
        s.push_str(&self.pipe);
        s
    }
}

impl Table {
    fn new(multi: &MultiProgress, names: &[String]) -> Self {
        let columns = names
            .iter()
            .map(|_| Column {
                live: Arc::new(LiveStats::new()),
                state: AtomicU8::new(FileState::Running as u8),
            })
            .collect::<Vec<_>>();

        let width = measured_term_width();
        let table = Table {
            line: static_line(multi),
            columns,
            names: names.to_vec(),
            state: Mutex::new(TableState {
                width,
                geometry: Geometry::new(names, width),
                last: String::new(),
            }),
        };
        table.refresh();
        table
    }

    /// Mark the table finished so it is not erased when the progress bars
    /// behind it are dropped at the end of the run.
    fn finish(&self) {
        self.line.finish();
    }

    /// Re-render the table from the latest published counters.
    ///
    /// Only the value cells are built here; everything that depends on the
    /// width alone lives in [`Geometry`] and is rebuilt only when the terminal
    /// is resized. The result is compared against the last frame and dropped if
    /// identical, which is the common case — columns only change every few
    /// thousand reads, and nothing changes at all while the reports are being
    /// written.
    fn refresh(&self) {
        let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());

        let width = measured_term_width();
        if width != state.width {
            state.width = width;
            state.geometry = Geometry::new(&self.names, width);
        }

        let Some(geometry) = &state.geometry else {
            // Too narrow for a readable table. Rendering nothing rather than
            // something cut off, and indicatif drops the line entirely.
            if !state.last.is_empty() {
                state.last = String::new();
                self.line.set_message("");
            }
            return;
        };

        let mut out: Vec<String> = Vec::with_capacity(geometry.row_labels.len() + 5);
        // A single space rather than an empty string: indicatif skips lines
        // that render to nothing, and the spacer is wanted.
        out.push(" ".to_string());
        out.push(geometry.top.clone());
        out.push(geometry.row(
            &geometry.heading_label,
            self.columns.iter().enumerate().map(|(index, column)| {
                let file_state = column.state.load(Ordering::Relaxed) as usize;
                let headings = &geometry.headings[index];
                headings.get(file_state).unwrap_or(&headings[0]).as_str()
            }),
        ));
        out.push(geometry.divider.clone());

        // A column that has not published yet shows "-" everywhere, so an idle
        // file reads as idle rather than as a file of zero reads.
        let values: Vec<Vec<String>> = self
            .columns
            .iter()
            .map(|column| {
                let snapshot = column.live.snapshot();
                column.live.request();
                match snapshot {
                    None => vec!["-".to_string(); geometry.row_labels.len()],
                    Some(counters) => counters.rows().into_iter().map(|(_, v)| v).collect(),
                }
            })
            .collect();

        // Row labels and ordering come straight from the report's own table.
        for (index, label) in geometry.row_labels.iter().enumerate() {
            let cells: Vec<String> = values
                .iter()
                .map(|column| pad_cell(&paint(&column[index], |s| s.white()), geometry.value_width))
                .collect();
            out.push(geometry.row(label, cells.iter().map(String::as_str)));
        }
        out.push(geometry.bottom.clone());

        let rendered = out.join("\n");
        if state.last == rendered {
            return;
        }
        // One update, one redraw.
        self.line.set_message(rendered.clone());
        state.last = rendered;
    }
}

/// Terminal columns consumed by a table of `columns` files that are not cell
/// content: two of indentation, a border between and either side of every
/// cell, and a space either side of each.
fn table_overhead(columns: usize) -> usize {
    2 + (columns + 2) + 2 * (columns + 1)
}

/// The `(label_width, value_width)` of a table for `columns` files at this
/// terminal width, or `None` when it is not worth drawing — which is when the
/// measure column cannot have its natural width or a value column would be
/// narrower than [`MIN_VALUE_WIDTH`].
///
/// This is the single place the geometry is decided; returning `None` is also
/// how the display decides not to show the table at all.
fn layout(columns: usize, term_width: usize) -> Option<(usize, usize)> {
    if columns == 0 {
        return None;
    }
    let label_width = BasicStatsCounters::MEASURES
        .iter()
        .map(|m| console::measure_text_width(m))
        .max()
        .unwrap_or(8);
    let available = term_width
        .saturating_sub(table_overhead(columns))
        .checked_sub(label_width)?;

    let value_width = (available / columns).min(MAX_VALUE_WIDTH);
    (value_width >= MIN_VALUE_WIDTH).then_some((label_width, value_width))
}

/// Fit a cell to exactly `width` columns, padding it out or truncating it with
/// an ellipsis. `console::pad_str` measures with `measure_text_width`, which
/// skips ANSI escapes, so a styled cell lines up with a plain one and is cut
/// without losing its trailing reset.
fn pad_cell(text: &str, width: usize) -> String {
    console::pad_str(text, width, console::Alignment::Left, Some("…")).into_owned()
}

/// Style an error line red for stderr.
fn paint_error(text: &str) -> String {
    paint(text, |s| s.red())
}

/// Style text for stderr.
///
/// `for_stderr` makes console resolve the escape codes against
/// `colors_enabled_stderr()` when the value is rendered, which is the same
/// signal indicatif's template styling uses — so there is nothing for this
/// module to decide or thread around.
fn paint(
    text: &str,
    apply: impl FnOnce(console::StyledObject<&str>) -> console::StyledObject<&str>,
) -> String {
    apply(style(text).for_stderr()).to_string()
}

/// Template key rendering the elapsed time compactly and dimmed, shared by
/// every bar style so the last column always looks the same.
fn elapsed_key(
) -> impl Fn(&indicatif::ProgressState, &mut dyn std::fmt::Write) + Clone + Send + Sync + 'static {
    move |state: &indicatif::ProgressState, w: &mut dyn std::fmt::Write| {
        let _ = write!(
            w,
            "{}",
            paint(&short_duration(state.elapsed()), |s| s.dim())
        );
    }
}

/// The FastQC logo's two colours, as the nearest xterm-256 entries.
///
/// Taken from the dark-background variant of the logo
/// (`docs/public/images/fastqc_logo_darkbg.svg`, `#659BFF` and `#AE3939`)
/// rather than the light-background one, whose navy `#000080` all but
/// disappears against a dark terminal. These mid-tones read on either.
///
/// Both are the closest entries in the 6x6x6 cube by CIELAB distance —
/// `#5f87ff` and `#d75f5f`. 256-colour rather than truecolor so that the one
/// code path works on every terminal; console emits 24-bit escapes without
/// checking whether the terminal can render them.
///
/// Public so the test that checks the banner is actually painted in them can
/// name them rather than restate the escape sequences.
pub const LOGO_BLUE: u8 = 69;
pub const LOGO_RED: u8 = 167;

/// Bar characters chosen to match the heavy-line look of Python's `rich`.
const PROGRESS_CHARS: &str = "━╸━";

/// Spinner frames. Inert for the styles whose template has no `{spinner}`.
const TICK_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";

/// Build a bar style. The display's styles differ only in the marker before
/// the bar, the bar's colour, and the field between the bar and the elapsed
/// time; everything else — the column layout, the bar characters and the
/// elapsed-time key — is shared, so that a change to the layout is made once.
///
/// `marker` and `middle` are substituted into the template, and `format!` does
/// not re-scan a substituted value for braces, so both can carry placeholders
/// of their own.
fn bar_style(marker: &str, color: &str, middle: &str) -> ProgressStyle {
    ProgressStyle::with_template(&format!(
        "  {{prefix}} {marker} {{wide_bar:.{color}/238}} {middle} {{elapsed:>5}}"
    ))
    .expect("static template")
    .progress_chars(PROGRESS_CHARS)
    .tick_chars(TICK_CHARS)
    .with_key("elapsed", elapsed_key())
}

/// The per-file fields: percentage, then a short status or read count.
const FILE_FIELDS: &str = "{percent:>3}% {msg:<12}";
/// The aggregate bar counts files rather than tracking one.
const AGGREGATE_FIELDS: &str = "{pos}/{len} files";

fn running_style() -> ProgressStyle {
    bar_style("{spinner:.cyan}", "cyan", FILE_FIELDS)
}

fn done_style() -> ProgressStyle {
    bar_style(&paint("✔", |s| s.green().bold()), "green", FILE_FIELDS)
}

fn failed_style() -> ProgressStyle {
    // Same column layout as the running and finished styles so a failed file
    // does not knock the other bars out of alignment. The bar is left at
    // whatever fraction of the file had been read when the error hit.
    bar_style(
        &paint("✘", |s| s.red().bold()),
        "red",
        &format!("{{percent:>3}}% {}", paint("{msg:<12}", |s| s.red())),
    )
}

fn aggregate_style() -> ProgressStyle {
    bar_style("{spinner:.cyan}", "cyan", AGGREGATE_FIELDS)
}

fn aggregate_done_style() -> ProgressStyle {
    bar_style(&paint("✔", |s| s.green().bold()), "green", AGGREGATE_FIELDS)
}

fn aggregate_failed_style() -> ProgressStyle {
    bar_style(&paint("✘", |s| s.red().bold()), "red", AGGREGATE_FIELDS)
}

/// Wall-clock elapsed time for the closing summary: `mm:ss`, widening to
/// `hh:mm:ss` past an hour rather than letting the minutes run past 59.
fn clock_duration(d: Duration) -> String {
    let secs = d.as_secs();
    if secs < 3600 {
        format!("{:02}:{:02}", secs / 60, secs % 60)
    } else {
        format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
    }
}

/// Compact elapsed time: `4.2s`, `1m12s`, `1h04m`.
fn short_duration(d: Duration) -> String {
    // Rounded before choosing the unit, so 59.96s reads `1m00s`, not `60.0s`.
    let tenths = (d.as_secs_f64() * 10.0).round() as u64;
    if tenths < 600 {
        return format!("{}.{}s", tenths / 10, tenths % 10);
    }
    let secs = d.as_secs().max(60);
    if secs < 3600 {
        format!("{}m{:02}s", secs / 60, secs % 60)
    } else {
        format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
    }
}

/// Abbreviate a read count for display: `812`, `12.3k`, `3.0M`.
///
/// The thresholds are set where the *rounded* value would tip over rather than
/// at the round number, so a count just short of the next unit reads as `1.0M`
/// and not `1000.0k`.
fn human_count(n: u64) -> String {
    if n >= 999_950_000 {
        format!("{:.1}B", n as f64 / 1e9)
    } else if n >= 999_950 {
        format!("{:.1}M", n as f64 / 1e6)
    } else if n >= 1_000 {
        format!("{:.1}k", n as f64 / 1e3)
    } else {
        n.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_human_count() {
        assert_eq!(human_count(0), "0");
        assert_eq!(human_count(999), "999");
        assert_eq!(human_count(1_500), "1.5k");
        assert_eq!(human_count(3_000_000), "3.0M");
        assert_eq!(human_count(2_500_000_000), "2.5B");

        // Just short of the next unit: the count must roll over rather than
        // print a mantissa that has rounded past the unit it is labelled with.
        assert_eq!(human_count(999_999), "1.0M");
        assert_eq!(human_count(999_999_999), "1.0B");
        // ...and just below the rollover it stays put.
        assert_eq!(human_count(999_949), "999.9k");
        assert_eq!(human_count(999_949_999), "999.9M");
    }

    #[test]
    fn test_short_duration() {
        assert_eq!(short_duration(Duration::from_millis(4200)), "4.2s");
        assert_eq!(short_duration(Duration::from_millis(59_960)), "1m00s");
        assert_eq!(short_duration(Duration::from_secs(72)), "1m12s");
        assert_eq!(short_duration(Duration::from_secs(3840)), "1h04m");
    }

    #[test]
    fn test_when_parse() {
        for on in [
            "always", "ALWAYS", " always ", "force", "1", "yes", "true", "on",
        ] {
            assert_eq!(When::parse(on), When::Always, "{on:?}");
        }
        for off in ["never", "Never", "none", "0", "no", "false", "off"] {
            assert_eq!(When::parse(off), When::Never, "{off:?}");
        }
        // Anything unrecognised falls back to detection rather than failing the
        // run over a display preference.
        for other in ["", "auto", "maybe", "yes please"] {
            assert_eq!(When::parse(other), When::Auto, "{other:?}");
        }
    }

    /// Auto-detection: the live display needs both a tty and a terminal that
    /// can be redrawn.
    #[test]
    fn test_choose_mode_auto() {
        let auto = When::Auto;
        // is_terminal, dumb
        assert_eq!(
            choose_mode(false, auto, true, false),
            ModeChoice::Live {
                bypass_detection: false
            }
        );
        // Piped or redirected stderr: plain lines, never bars.
        assert_eq!(choose_mode(false, auto, false, false), ModeChoice::Plain);
        // A tty that cannot redraw (TERM=dumb, or unset on Unix): indicatif
        // would hide the bars, so fall back rather than going silent.
        assert_eq!(choose_mode(false, auto, true, true), ModeChoice::Plain);
        assert_eq!(choose_mode(false, auto, false, true), ModeChoice::Plain);
    }

    /// `FASTQC_PROGRESS=always|never` overrides the detection in both
    /// directions. `always` bypasses indicatif's self-hiding draw target only
    /// where that target would in fact hide — driving the terminal by hand on a
    /// terminal that works would be a downgrade, not a force.
    #[test]
    fn test_choose_mode_forced() {
        // is_terminal, dumb, and whether the display has to be driven by hand.
        // Spelled out rather than recomputed from the rule, so that getting the
        // rule wrong fails the test instead of being copied into it.
        for (is_terminal, dumb, bypass_detection) in [
            (true, false, false),
            (true, true, true),
            (false, false, true),
            (false, true, true),
        ] {
            assert_eq!(
                choose_mode(false, When::Always, is_terminal, dumb),
                ModeChoice::Live { bypass_detection },
                "always must draw the display (tty={is_terminal}, dumb={dumb})"
            );
            assert_eq!(
                choose_mode(false, When::Never, is_terminal, dumb),
                ModeChoice::Plain,
                "never must not draw the display (tty={is_terminal}, dumb={dumb})"
            );
        }
    }

    /// `--quiet` is the stronger statement and beats an explicit `always`.
    #[test]
    fn test_quiet_beats_forced_progress() {
        for progress in [When::Auto, When::Always, When::Never] {
            for &is_terminal in &[true, false] {
                assert_eq!(
                    choose_mode(true, progress, is_terminal, false),
                    ModeChoice::Silent,
                    "--quiet must win over {progress:?}"
                );
            }
        }
    }

    /// With no display active, a log line still reaches stderr rather than
    /// being swallowed.
    #[test]
    fn test_log_line_without_a_display() {
        assert!(ACTIVE_LOG
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .is_none());
        log_line("no display active, so this goes straight to stderr");
    }

    /// Whenever the table is shown, it must fit inside the terminal, with the
    /// measure column at its natural width and every value column readable.
    #[test]
    fn test_a_shown_table_renders_inside_the_terminal() {
        let natural_label = BasicStatsCounters::MEASURES
            .iter()
            .map(|m| console::measure_text_width(m))
            .max()
            .unwrap();
        for term_width in [40usize, 60, 72, 80, 100, 120, 160, 200, 400] {
            for columns in 1..=12 {
                let Some((label_width, value_width)) = layout(columns, term_width) else {
                    continue;
                };
                let total = table_overhead(columns) + label_width + value_width * columns;
                assert!(
                    total <= term_width,
                    "shown table of {columns} columns overflows {term_width} cols (needs {total})"
                );
                assert!(
                    value_width >= MIN_VALUE_WIDTH,
                    "value column below the readable minimum at {term_width} cols"
                );
                assert_eq!(
                    label_width, natural_label,
                    "measure column was squeezed at {term_width} cols"
                );
            }
        }
    }

    /// The decision is made on width, not on how many files there are: a wider
    /// terminal earns more columns, a narrow one loses the table entirely.
    #[test]
    fn test_table_visibility_follows_terminal_width() {
        let fits = |columns, width| layout(columns, width).is_some();

        // Nothing to tabulate.
        assert!(!fits(0, 200));

        // 80 columns is enough for up to three files, not four.
        assert!(fits(1, 80));
        assert!(fits(3, 80));
        assert!(!fits(4, 80));

        // Widening the terminal brings more columns into range, and the
        // threshold only ever moves one way.
        for columns in 1..=10 {
            // The first width that fits. Asserting that narrower ones do not
            // would be vacuous — `find` returns the smallest by definition.
            // What has to hold is the other direction: every wider terminal
            // fits too, so the table never blinks out again as the window
            // grows.
            let threshold = (1..600)
                .find(|w| fits(columns, *w))
                .expect("some width is wide enough");
            assert!(
                (threshold..600).all(|w| fits(columns, w)),
                "{columns} columns: fits at {threshold} but not at every wider width"
            );
            // More files always need at least as much room.
            if columns > 1 {
                let narrower = (1..600).find(|w| fits(columns - 1, *w)).unwrap();
                assert!(narrower < threshold);
            }
        }

        // A 24-column terminal is never wide enough.
        assert!(!fits(1, 24));
    }

    /// A cell always occupies exactly its column, whether it had to be padded
    /// out or cut down, and styling it does not change that: widths are
    /// measured in display columns, so the escapes do not count.
    #[test]
    fn test_pad_cell_fits_the_column_around_ansi() {
        let styled = style("abc").red().force_styling(true).to_string();
        let padded = pad_cell(&styled, 6);
        assert_eq!(console::measure_text_width(&padded), 6);
        assert!(padded.starts_with(&styled));

        let long = style("abcdefghij").red().force_styling(true).to_string();
        let cut = pad_cell(&long, 6);
        assert_eq!(console::measure_text_width(&cut), 6);
        assert!(cut.contains('…'), "not truncated with an ellipsis: {cut:?}");
        assert!(cut.ends_with("\u{1b}[0m"), "lost its reset: {cut:?}");
    }

    /// The blank line that separates log messages from the bars appears the
    /// first time something is logged, and not before: a run that logs nothing
    /// should not gain a stray gap above its bars.
    #[test]
    fn test_log_padding_appears_with_the_first_message() {
        let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
        let sink = LogSink {
            multi: multi.clone(),
            line_ending: "\n",
            padding: static_line(&multi),
        };
        assert_eq!(
            sink.padding.message(),
            "",
            "padded before anything was said"
        );

        sink.print("something happened");
        assert_eq!(sink.padding.message(), " ", "no padding after a message");

        // Still exactly one blank line, however much is logged.
        sink.print("and again");
        assert_eq!(sink.padding.message(), " ");
    }

    /// The table's furniture is rebuilt for whatever width it is asked for, so
    /// a window resized mid-run gets a table laid out for the new size rather
    /// than one cut to the old one — and the table drops out entirely, and
    /// comes back, as the width crosses the readable threshold.
    #[test]
    fn test_geometry_follows_the_width_it_is_built_for() {
        let names = vec![
            "sample_1.fastq.gz".to_string(),
            "sample_2.fastq.gz".to_string(),
        ];

        let narrow = Geometry::new(&names, 50);
        assert!(narrow.is_none(), "two columns cannot be readable at 50");

        let wide = Geometry::new(&names, 200).expect("two columns fit at 200");
        let wider = Geometry::new(&names, 400).expect("two columns fit at 400");
        assert_eq!(wide.headings.len(), names.len());

        // Every rendered row is exactly as wide as the border above it, and no
        // wider than the terminal it was laid out for.
        for (geometry, term_width) in [(&wide, 200usize), (&wider, 400)] {
            let heading = geometry.row(
                &geometry.heading_label,
                geometry.headings.iter().map(|h| h[0].as_str()),
            );
            for line in [&geometry.top, &geometry.divider, &geometry.bottom, &heading] {
                assert_eq!(
                    console::measure_text_width(line),
                    console::measure_text_width(&geometry.top),
                    "table lines disagree on width at {term_width} cols"
                );
                assert!(
                    console::measure_text_width(line) <= term_width,
                    "table overflows {term_width} cols"
                );
            }
        }

        // A value column never grows past its cap, however wide the window.
        assert_eq!(wider.value_width, MAX_VALUE_WIDTH);
    }

    /// A hidden reporter must be safe to drive exactly like a live one.
    #[test]
    fn test_hidden_reporter_is_inert() {
        let reporter = ProgressReporter::hidden();
        let file = reporter.file(0);
        file.start("a.fastq");
        file.update(1000, || 50.0);
        file.stage("writing report");
        file.finish("a.fastq", 2000);
        file.fail();
        assert!(file.live_stats().is_none());
        reporter.finish(1, false);
    }
}