rxls 0.1.1

Native Rust spreadsheet library: reads .xls (BIFF8/5/7), .xlsx, .xlsb, .ods and writes .xlsx. Typed cells, formulas, panic-free, no JVM/POI.
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
//! Editable spreadsheet wrapper with retained OOXML package bytes.

use quick_xml::events::Event;
use quick_xml::{Reader, XmlVersion};

use crate::write::xml::{a1, esc_attr, esc_text, num_str};
use crate::xmltree::{NodeId, XmlTree};
use crate::{package::Package, Cell, Color, DocProperties, Error, Result, SheetVisible, Workbook};

const MAX_EDIT_RANGE_CELLS: u64 = 10_000;
/// Canonical package-relative path of the calculation chain part, used to
/// precisely match `PartName`/`Target` references rather than substring-match
/// the whole element text (a sibling part such as `worksheets/precalcChained.xml`
/// must not be treated as the calc chain merely because it contains the
/// substring "calcChain").
const CALC_CHAIN_PART: &str = "xl/calcChain.xml";

/// Edit/save capability for a [`Spreadsheet`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditCapability {
    /// The workbook came from an OOXML package and can be saved without
    /// regenerating unknown parts.
    ReadWrite,
    /// The workbook can be read, but this wrapper cannot preserve edits for its
    /// source format.
    ReadOnly(EditReadOnlyReason),
}

/// Why a [`Spreadsheet`] cannot be edited/saved package-preservingly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditReadOnlyReason {
    /// Legacy OLE2/BIFF `.xls`.
    LegacyBiff,
    /// Binary ZIP package such as `.xlsb`.
    BinaryPackage,
    /// OpenDocument spreadsheet package.
    OpenDocument,
    /// The OOXML package could not be retained losslessly enough for editing.
    PackageMetadataLoss,
}

/// A workbook plus the original package bytes needed for no-loss `.xlsx/.xlsm`
/// save.
#[derive(Debug, Clone)]
pub struct Spreadsheet {
    workbook: Workbook,
    package: Option<Package>,
    capability: EditCapability,
    edited_parts: Vec<String>,
}

impl Spreadsheet {
    /// Open a spreadsheet for read access and, for `.xlsx/.xlsm`, retained-package
    /// save.
    pub fn open(bytes: &[u8]) -> Result<Self> {
        #[cfg(feature = "xlsb")]
        if crate::xlsb::is_xlsb(bytes) {
            return Ok(Self::read_only(
                crate::xlsb::open(bytes)?,
                EditReadOnlyReason::BinaryPackage,
            ));
        }
        #[cfg(feature = "ods")]
        if crate::ods::is_ods(bytes) {
            return Ok(Self::read_only(
                crate::ods::open(bytes)?,
                EditReadOnlyReason::OpenDocument,
            ));
        }

        if crate::xlsx::is_xlsx(bytes) {
            let package = Package::from_bytes(bytes)?;
            let workbook = crate::xlsx::open(bytes)?;
            // Lenient-read / strict-edit asymmetry: an incomplete or
            // metadata-lossy package still opens (and still supports a no-op
            // `save()`, since `Package::to_bytes` never itself consults these
            // flags), but edit methods must refuse rather than risk
            // regenerating OPC metadata lossily.
            let capability = if !package.is_complete() || package.is_meta_lossy() {
                EditCapability::ReadOnly(EditReadOnlyReason::PackageMetadataLoss)
            } else {
                EditCapability::ReadWrite
            };
            return Ok(Self {
                workbook,
                package: Some(package),
                capability,
                edited_parts: Vec::new(),
            });
        }

        Ok(Self::read_only(
            Workbook::open_with_codepage(bytes, None)?,
            EditReadOnlyReason::LegacyBiff,
        ))
    }

    fn read_only(workbook: Workbook, reason: EditReadOnlyReason) -> Self {
        Self {
            workbook,
            package: None,
            capability: EditCapability::ReadOnly(reason),
            edited_parts: Vec::new(),
        }
    }

    /// Parsed workbook view.
    pub fn workbook(&self) -> &Workbook {
        &self.workbook
    }

    /// Whether this spreadsheet can be saved through the retained package path.
    pub fn edit_capability(&self) -> &EditCapability {
        &self.capability
    }

    /// The capability-gate step of the edit recipe: every mutating method
    /// must call this before touching any part, so a read-only-for-edits
    /// spreadsheet (legacy format, or a package that opened with
    /// incomplete/metadata-lossy parts) can never partially apply an edit.
    ///
    /// Takes `&self` (not `&mut self`) so callers can still borrow
    /// `self.package`/`self.edited_parts` disjointly afterward.
    fn ensure_editable(&self) -> Result<()> {
        if self.capability != EditCapability::ReadWrite {
            return Err(Error::Zip(
                "spreadsheet is read-only for package-preserving edit",
            ));
        }
        Ok(())
    }

    /// Package parts edited since open, in deterministic part-name order.
    pub fn edited_parts(&self) -> &[String] {
        &self.edited_parts
    }

    /// Set a worksheet cell in the retained OOXML package.
    ///
    /// The parsed [`Workbook`] view is intentionally not mutated; reopen the
    /// saved bytes to observe edited values through read APIs.
    pub fn set_cell_value(
        &mut self,
        sheet_name: &str,
        row: u32,
        col: u16,
        value: Cell,
    ) -> Result<()> {
        if row > 1_048_575 || col > 16_383 {
            return Err(Error::Zip("cell is outside the Excel grid"));
        }
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let path = worksheet_path(package, sheet_name)?;
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&path)?;
        sml_edit_cell(tree, row, col, &value)?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        for touched in invalidate_calc_chain(package)? {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Set a worksheet formula and cached value in the retained OOXML package.
    pub fn set_cell_formula(
        &mut self,
        sheet_name: &str,
        row: u32,
        col: u16,
        formula: impl AsRef<str>,
        cached: impl Into<Cell>,
    ) -> Result<()> {
        self.set_cell_value(
            sheet_name,
            row,
            col,
            Cell::Formula {
                formula: formula.as_ref().trim_start_matches('=').to_string(),
                cached: Box::new(cached.into()),
            },
        )
    }

    /// Append one row of cells to the target worksheet XML part.
    ///
    /// Returns the appended zero-based row index. Text is written as inline
    /// strings, matching [`Spreadsheet::set_cell_value`].
    pub fn append_row<I>(&mut self, sheet_name: &str, values: I) -> Result<u32>
    where
        I: IntoIterator<Item = Cell>,
    {
        let values: Vec<Cell> = values.into_iter().collect();
        if values.len() > 16_384 {
            return Err(Error::Zip("row is outside the Excel grid"));
        }
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let path = worksheet_path(package, sheet_name)?;
        // Compute the append row from a read-only peek *before* promoting the
        // part for editing: if the bounds check below fails, the part must
        // stay completely untouched (no promotion, no `touched`/re-serialize),
        // matching the read-then-validate-then-mutate ordering the old
        // string-splicing code got for free by only calling `replace_part`
        // after every fallible step succeeded.
        let row = peek_part_tree(
            package,
            &path,
            Error::Zip("worksheet XML is missing"),
            |tree| Ok(sml_next_append_row(tree)),
        )?;
        if row > 1_048_575 {
            return Err(Error::Zip("row is outside the Excel grid"));
        }
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&path)?;
        for (col, value) in values.into_iter().enumerate() {
            sml_edit_cell(tree, row, col as u16, &value)?;
        }
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        for touched in invalidate_calc_chain(package)? {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(row)
    }

    /// Clear cells in an inclusive target range.
    pub fn clear_range(
        &mut self,
        sheet_name: &str,
        start_row: u32,
        start_col: u16,
        end_row: u32,
        end_col: u16,
    ) -> Result<()> {
        let row0 = start_row.min(end_row);
        let row1 = start_row.max(end_row);
        let col0 = start_col.min(end_col);
        let col1 = start_col.max(end_col);
        if row1 > 1_048_575 || col1 > 16_383 {
            return Err(Error::Zip("range is outside the Excel grid"));
        }
        let row_count = row1.saturating_sub(row0).saturating_add(1) as u64;
        let col_count = u64::from(col1.saturating_sub(col0).saturating_add(1));
        if row_count.saturating_mul(col_count) > MAX_EDIT_RANGE_CELLS {
            return Err(Error::Zip("range is too large for package-preserving edit"));
        }

        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let path = worksheet_path(package, sheet_name)?;
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&path)?;
        for row in row0..=row1 {
            for col in col0..=col1 {
                sml_clear_cell(tree, row, col)?;
            }
        }
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        for touched in invalidate_calc_chain(package)? {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Set workbook document properties in the retained OOXML package.
    ///
    /// Core properties are edited in place in `docProps/core.xml` (only the
    /// `Some` fields are written -- the rest are left as-is if present, or
    /// removed if `None` and previously present); the extended company
    /// property is updated the same way in `docProps/app.xml` when that part
    /// exists, and only actually touched if the company value changes. The
    /// parsed [`Workbook`] view is intentionally not mutated.
    pub fn set_document_properties(&mut self, properties: DocProperties) -> Result<()> {
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;

        let before = package.touched_parts();
        let tree = package.part_tree_mut("docProps/core.xml")?;
        core_set_properties(tree, &properties)?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }

        if package.has_part("docProps/app.xml") {
            let changed = peek_part_tree(
                package,
                "docProps/app.xml",
                Error::Zip("docProps/app.xml is missing"),
                |tree| Ok(app_company_changed(tree, properties.company.as_deref())),
            )?;
            if changed {
                let before = package.touched_parts();
                let tree = package.part_tree_mut("docProps/app.xml")?;
                app_set_company(tree, properties.company.as_deref())?;
                for touched in newly_touched(&before, package) {
                    remember_edited_part(&mut self.edited_parts, touched);
                }
            }
        }

        Ok(())
    }

    /// Set or replace a workbook-global defined name in `xl/workbook.xml`.
    ///
    /// Sheet-local and built-in `_xlnm.*` names are left untouched.
    pub fn set_defined_name(
        &mut self,
        name: impl AsRef<str>,
        refers_to: impl AsRef<str>,
    ) -> Result<()> {
        let name = name.as_ref();
        if name.trim().is_empty() || name.starts_with("_xlnm.") {
            return Err(Error::Zip("defined name is not editable"));
        }
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let workbook_path = workbook_path(package);
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&workbook_path)?;
        sml_set_global_defined_name(tree, name, refers_to.as_ref())?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Rename a worksheet in `xl/workbook.xml`.
    pub fn rename_sheet(&mut self, old_name: &str, new_name: &str) -> Result<()> {
        validate_sheet_name(new_name)?;
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let workbook_path = workbook_path(package);
        let exists = peek_part_tree(package, &workbook_path, Error::MissingWorkbook, |tree| {
            Ok(workbook_sheet_index(tree, new_name).is_some())
        })?;
        if exists {
            return Err(Error::Zip("sheet name already exists"));
        }
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&workbook_path)?;
        let sheet = sml_find_sheet_by_name(tree, old_name).ok_or(Error::MissingWorkbook)?;
        tree.set_attr(sheet, b"name", new_name.as_bytes())?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Set a worksheet visibility state in `xl/workbook.xml`.
    pub fn set_sheet_visibility(&mut self, sheet_name: &str, visible: SheetVisible) -> Result<()> {
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let workbook_path = workbook_path(package);
        // Validate (existence + "at least one visible sheet") against a
        // read-only peek *before* promoting the part, so a rejected edit
        // leaves `xl/workbook.xml` completely untouched.
        peek_part_tree(package, &workbook_path, Error::MissingWorkbook, |tree| {
            let sheet = sml_find_sheet_by_name(tree, sheet_name).ok_or(Error::MissingWorkbook)?;
            if visible != SheetVisible::Visible
                && sheet_visibility_of(tree, sheet) == SheetVisible::Visible
                && visible_sheet_count(tree) <= 1
            {
                return Err(Error::Zip("cannot hide the last visible sheet"));
            }
            Ok(())
        })?;

        let before = package.touched_parts();
        let tree = package.part_tree_mut(&workbook_path)?;
        let sheet = sml_find_sheet_by_name(tree, sheet_name).ok_or(Error::MissingWorkbook)?;
        match visible {
            SheetVisible::Visible => tree.remove_attr(sheet, b"state"),
            SheetVisible::Hidden => tree.set_attr(sheet, b"state", b"hidden")?,
            SheetVisible::VeryHidden => tree.set_attr(sheet, b"state", b"veryHidden")?,
        }
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Set the active worksheet by name in `xl/workbook.xml`.
    pub fn set_active_sheet(&mut self, sheet_name: &str) -> Result<()> {
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let workbook_path = workbook_path(package);
        let index = peek_part_tree(package, &workbook_path, Error::MissingWorkbook, |tree| {
            workbook_sheet_index(tree, sheet_name).ok_or(Error::MissingWorkbook)
        })?;
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&workbook_path)?;
        sml_set_active_tab(tree, index)?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Set worksheet tab color in the target worksheet XML part.
    pub fn set_sheet_tab_color(&mut self, sheet_name: &str, color: impl Into<Color>) -> Result<()> {
        self.ensure_editable()?;
        let package = self.package.as_mut().ok_or(Error::Zip(
            "spreadsheet is read-only for package-preserving edit",
        ))?;
        let path = worksheet_path(package, sheet_name)?;
        let before = package.touched_parts();
        let tree = package.part_tree_mut(&path)?;
        sml_set_tab_color(tree, color.into())?;
        for touched in newly_touched(&before, package) {
            remember_edited_part(&mut self.edited_parts, touched);
        }
        Ok(())
    }

    /// Save the retained OOXML package.
    pub fn save(&self) -> Result<Vec<u8>> {
        match &self.package {
            Some(package) => package.to_bytes(),
            None => Err(Error::Zip(
                "spreadsheet is read-only for package-preserving save",
            )),
        }
    }
}

fn remember_edited_part(parts: &mut Vec<String>, part: String) {
    if !parts.iter().any(|p| p == &part) {
        parts.push(part);
        parts.sort();
    }
}

/// Remove any residual calc-chain wiring after a cell edit: the
/// `xl/calcChain.xml` part itself, `[Content_Types].xml`'s `Override` for it,
/// and the workbook `.rels` `Relationship` that points at it. Every removal
/// is exact part-path/target equality (via [`canonical_part_name`] /
/// [`normalize_part_target`]), never a substring match, so a sibling part
/// whose path merely *contains* "calcChain" (e.g.
/// `xl/worksheets/precalcChained.xml`) is never mistaken for the real part.
///
/// Each of `[Content_Types].xml`/the workbook `.rels` part is only promoted
/// (via [`Package::part_tree_mut`]) when a read-only peek first confirms
/// there is actually a matching entry to remove -- so a save with nothing to
/// invalidate leaves those parts completely untouched, exactly as the old
/// string-splicing version did by only calling `replace_part` when its
/// edited XML actually differed from the original.
fn invalidate_calc_chain(package: &mut Package) -> Result<Vec<String>> {
    let mut touched = Vec::new();
    if let Some(name) = package.remove_part(CALC_CHAIN_PART) {
        touched.push(name);
    }

    if let Some(name) = remove_matching_child(package, "[Content_Types].xml", b"PartName", &|v| {
        canonical_part_name(v) == CALC_CHAIN_PART
    })? {
        touched.push(name);
    }

    let workbook_path = workbook_path(package);
    let rels_path = rels_path_for(&workbook_path);
    if let Some(name) = remove_matching_child(package, &rels_path, b"Target", &|v| {
        normalize_part_target(&workbook_path, v) == CALC_CHAIN_PART
    })? {
        touched.push(name);
    }

    touched.sort();
    touched.dedup();
    Ok(touched)
}

/// Set (or, if `Some(value)` and absent, insert as the last child) a
/// Dublin Core / extended-properties text-only child element by exact
/// qualified tag name -- `Some(value)` sets its text in place when the
/// element already exists, preserving every attribute/sibling it carries;
/// `None` removes the element if present (no-op otherwise). Never rebuilds
/// `root` or any of its other children. Shared by `docProps/core.xml`'s
/// Dublin Core fields and `docProps/app.xml`'s `<Company>`.
fn set_or_remove_child_text(
    tree: &mut XmlTree,
    root: NodeId,
    tag: &str,
    value: Option<&str>,
) -> Result<()> {
    match (tree.child_by_name(root, tag.as_bytes()), value) {
        (Some(id), Some(v)) => tree.set_element_text(id, v),
        (Some(id), None) => tree.remove_child(root, id),
        (None, Some(v)) => {
            let frag = format!("<{tag}>{}</{tag}>", esc_text(v));
            let idx = tree.children_of(root).len();
            tree.insert_fragment_at(root, idx, frag.as_bytes())?;
            Ok(())
        }
        (None, None) => Ok(()),
    }
}

/// Set (or remove) `docProps/core.xml`'s `dcterms:created`/`dcterms:modified`
/// pair together, both carrying `xsi:type="dcterms:W3CDTF"`. `created` must
/// already be validated (see [`crate::write::is_w3cdtf`]) -- `None` removes
/// both elements if present, matching how an invalid/absent timestamp is
/// simply omitted.
fn core_set_timestamp_pair(tree: &mut XmlTree, root: NodeId, ts: Option<&str>) -> Result<()> {
    for tag in ["dcterms:created", "dcterms:modified"] {
        match (tree.child_by_name(root, tag.as_bytes()), ts) {
            (Some(id), Some(v)) => {
                tree.set_attr(id, b"xsi:type", b"dcterms:W3CDTF")?;
                tree.set_element_text(id, v)?;
            }
            (Some(id), None) => tree.remove_child(root, id)?,
            (None, Some(v)) => {
                let frag = format!(
                    r#"<{tag} xsi:type="dcterms:W3CDTF">{}</{tag}>"#,
                    esc_text(v)
                );
                let idx = tree.children_of(root).len();
                tree.insert_fragment_at(root, idx, frag.as_bytes())?;
            }
            (None, None) => {}
        }
    }
    Ok(())
}

/// Apply every `Some` field of `p` onto `docProps/core.xml`'s
/// `<cp:coreProperties>` children, in place: each field is written if
/// `Some`, or its existing element (if any) removed if `None` -- exactly
/// mirroring the old whole-part-regeneration's "only `Some` fields survive"
/// semantics, but without rebuilding anything this module doesn't model
/// (unknown elements, attributes, namespace decls, comments all ride along
/// untouched).
fn core_set_properties(tree: &mut XmlTree, p: &DocProperties) -> Result<()> {
    let root = tree
        .root_element()
        .ok_or(Error::Zip("docProps/core.xml is malformed"))?;
    set_or_remove_child_text(tree, root, "dc:title", p.title.as_deref())?;
    set_or_remove_child_text(tree, root, "dc:subject", p.subject.as_deref())?;
    set_or_remove_child_text(tree, root, "dc:creator", p.creator.as_deref())?;
    set_or_remove_child_text(tree, root, "cp:keywords", p.keywords.as_deref())?;
    set_or_remove_child_text(tree, root, "dc:description", p.description.as_deref())?;
    set_or_remove_child_text(
        tree,
        root,
        "cp:lastModifiedBy",
        p.last_modified_by.as_deref(),
    )?;
    // Only a value shaped like W3CDTF may carry `xsi:type="dcterms:W3CDTF"`,
    // matching `core_xml_with_budget`'s validation (a malformed timestamp
    // would otherwise make the part schema-invalid).
    let ts = p
        .created
        .as_deref()
        .filter(|ts| crate::write::is_w3cdtf(ts));
    core_set_timestamp_pair(tree, root, ts)?;
    Ok(())
}

/// The current text of `docProps/app.xml`'s `<Company>`, if present.
fn app_company_text(tree: &XmlTree) -> Option<String> {
    let root = tree.root_element()?;
    let company = tree.child_by_name(root, b"Company")?;
    Some(tree.text_of(company))
}

/// Whether setting the company to `desired` would actually change
/// `docProps/app.xml` -- lets [`Spreadsheet::set_document_properties`] only
/// promote/touch that part when the company value genuinely differs, exactly
/// matching the old `edited != app_xml` no-op check.
fn app_company_changed(tree: &XmlTree, desired: Option<&str>) -> bool {
    app_company_text(tree).as_deref() != desired
}

/// Set (or remove) `docProps/app.xml`'s `<Company>` in place.
fn app_set_company(tree: &mut XmlTree, company: Option<&str>) -> Result<()> {
    let root = tree
        .root_element()
        .ok_or(Error::Zip("docProps/app.xml is malformed"))?;
    set_or_remove_child_text(tree, root, "Company", company)
}

fn validate_sheet_name(name: &str) -> Result<()> {
    if name.trim().is_empty()
        || name.chars().count() > 31
        || name
            .chars()
            .any(|ch| matches!(ch, ':' | '\\' | '/' | '?' | '*' | '[' | ']'))
    {
        return Err(Error::Zip("invalid sheet name"));
    }
    Ok(())
}

/// Find the `<sheet name="...">` child of `xl/workbook.xml`'s `<sheets>`
/// element by exact name match.
fn sml_find_sheet_by_name(tree: &XmlTree, name: &str) -> Option<NodeId> {
    let workbook = tree.root_element()?;
    let sheets = tree.child_by_name(workbook, b"sheets")?;
    tree.children_of(sheets).iter().copied().find(|&c| {
        tree.attr_value(c, b"name")
            .and_then(|v| std::str::from_utf8(v).ok())
            == Some(name)
    })
}

/// 0-based ordinal of the `<sheet name="...">` among `<sheets>`'s `<sheet>`
/// children (document order), or `None` if no sheet has that name. Filters
/// to actual `<sheet>` elements via [`XmlTree::element_name`] -- not just a
/// raw child-list position -- so a pretty-printed part with whitespace `Text`
/// nodes interleaved between `<sheet>` elements still yields the correct
/// sheet ordinal.
fn workbook_sheet_index(tree: &XmlTree, name: &str) -> Option<usize> {
    let workbook = tree.root_element()?;
    let sheets = tree.child_by_name(workbook, b"sheets")?;
    tree.children_of(sheets)
        .iter()
        .filter(|&&c| tree.element_name(c) == Some(b"sheet"))
        .position(|&c| {
            tree.attr_value(c, b"name")
                .and_then(|v| std::str::from_utf8(v).ok())
                == Some(name)
        })
}

/// A `<sheet>` node's visibility, read from its `state` attribute (absent ⇒
/// visible).
fn sheet_visibility_of(tree: &XmlTree, sheet: NodeId) -> SheetVisible {
    match tree
        .attr_value(sheet, b"state")
        .and_then(|v| std::str::from_utf8(v).ok())
    {
        Some("hidden") => SheetVisible::Hidden,
        Some("veryHidden") => SheetVisible::VeryHidden,
        _ => SheetVisible::Visible,
    }
}

/// Count of `<sheets>`'s `<sheet>` children that are not `hidden`/`veryHidden`
/// -- see [`workbook_sheet_index`] for why this filters to actual `<sheet>`
/// elements rather than a raw child count.
fn visible_sheet_count(tree: &XmlTree) -> usize {
    let Some(workbook) = tree.root_element() else {
        return 0;
    };
    let Some(sheets) = tree.child_by_name(workbook, b"sheets") else {
        return 0;
    };
    tree.children_of(sheets)
        .iter()
        .filter(|&&c| tree.element_name(c) == Some(b"sheet"))
        .filter(|&&c| sheet_visibility_of(tree, c) == SheetVisible::Visible)
        .count()
}

/// Find the workbook-global (non-sheet-local) `<definedName name="...">`
/// child of `xl/workbook.xml`'s `<definedNames>` element, if any.
fn sml_defined_name_node(tree: &XmlTree, workbook: NodeId, name: &str) -> Option<NodeId> {
    let defined_names = tree.child_by_name(workbook, b"definedNames")?;
    tree.children_of(defined_names).iter().copied().find(|&c| {
        tree.attr_value(c, b"localSheetId").is_none()
            && tree
                .attr_value(c, b"name")
                .and_then(|v| std::str::from_utf8(v).ok())
                == Some(name)
    })
}

/// Insert-or-replace-by-name a workbook-global defined name: if a global
/// `<definedName name="X">` already exists, only its text is replaced
/// (preserving any other attribute it carries); otherwise a new element is
/// appended to `<definedNames>` (creating that element, as the workbook's
/// last child, if it doesn't exist yet either).
fn sml_set_global_defined_name(tree: &mut XmlTree, name: &str, refers_to: &str) -> Result<()> {
    let workbook = tree.root_element().ok_or(Error::MissingWorkbook)?;
    if let Some(existing) = sml_defined_name_node(tree, workbook, name) {
        return tree.set_element_text(existing, refers_to);
    }
    let frag = format!(
        r#"<definedName name="{}">{}</definedName>"#,
        esc_attr(name),
        esc_text(refers_to)
    );
    if let Some(defined_names) = tree.child_by_name(workbook, b"definedNames") {
        let idx = tree.children_of(defined_names).len();
        tree.insert_fragment_at(defined_names, idx, frag.as_bytes())?;
        return Ok(());
    }
    let idx = tree.children_of(workbook).len();
    let wrapped = format!("<definedNames>{frag}</definedNames>");
    tree.insert_fragment_at(workbook, idx, wrapped.as_bytes())?;
    Ok(())
}

/// Set (or create) `xl/workbook.xml`'s
/// `<bookViews><workbookView activeTab="N"/></bookViews>`, preserving any
/// other attribute an existing `<workbookView>` carries and inserting a
/// missing `<bookViews>` in `CT_Workbook` order (right before `<sheets>`).
fn sml_set_active_tab(tree: &mut XmlTree, index: usize) -> Result<()> {
    let workbook = tree.root_element().ok_or(Error::MissingWorkbook)?;
    let index = index.to_string();
    let book_views = match tree.child_by_name(workbook, b"bookViews") {
        Some(id) => id,
        None => {
            let sheets = tree.child_by_name(workbook, b"sheets");
            let insert_idx = sheets
                .and_then(|s| tree.children_of(workbook).iter().position(|&c| c == s))
                .unwrap_or_else(|| tree.children_of(workbook).len());
            tree.insert_fragment_at(workbook, insert_idx, b"<bookViews></bookViews>")?
        }
    };
    match tree.child_by_name(book_views, b"workbookView") {
        Some(view) => tree.set_attr(view, b"activeTab", index.as_bytes())?,
        None => {
            let frag = format!(r#"<workbookView activeTab="{index}"/>"#);
            let idx = tree.children_of(book_views).len();
            tree.insert_fragment_at(book_views, idx, frag.as_bytes())?;
        }
    }
    Ok(())
}

/// Set (or create) the worksheet's `<sheetPr><tabColor rgb="..."/></sheetPr>`.
/// An existing `tabColor` is edited in place but reduced to just `rgb`
/// (matching the previous string-splicing output shape exactly: any
/// `indexed`/`theme`/`tint`/`auto` color encoding is cleared), while every
/// other `sheetPr` child/attribute -- and everything else in the part --
/// rides along untouched.
fn sml_set_tab_color(tree: &mut XmlTree, color: Color) -> Result<()> {
    let worksheet = tree
        .root_element()
        .ok_or(Error::Zip("worksheet XML is malformed"))?;
    let rgb = color_hex(color);
    let Some(sheet_pr) = tree.child_by_name(worksheet, b"sheetPr") else {
        let frag = format!(r#"<sheetPr><tabColor rgb="{rgb}"/></sheetPr>"#);
        tree.insert_fragment_at(worksheet, 0, frag.as_bytes())?;
        return Ok(());
    };
    match tree.child_by_name(sheet_pr, b"tabColor") {
        Some(tab_color) => {
            for attr in [b"indexed".as_slice(), b"theme", b"tint", b"auto"] {
                tree.remove_attr(tab_color, attr);
            }
            tree.set_attr(tab_color, b"rgb", rgb.as_bytes())?;
        }
        None => {
            // `tabColor` is CT_SheetPr's first child (before `outlinePr`,
            // `pageSetUpPr`, ...) -- prepend, matching the old
            // string-splicing insertion right after `<sheetPr ...>`'s open
            // tag, ahead of any existing children.
            let frag = format!(r#"<tabColor rgb="{rgb}"/>"#);
            tree.insert_fragment_at(sheet_pr, 0, frag.as_bytes())?;
        }
    }
    Ok(())
}

fn color_hex(color: Color) -> String {
    format!("FF{:02X}{:02X}{:02X}", color.0[0], color.0[1], color.0[2])
}

fn worksheet_path(package: &Package, sheet_name: &str) -> Result<String> {
    let workbook_path = workbook_path(package);
    // `xl/workbook.xml` may already be promoted to an edited tree by an
    // earlier sheet-metadata edit in this session (rename/visibility/active
    // tab/defined name) -- `part_xml_bytes` sees that case too, where a bare
    // `Package::part_bytes` (which only reads still-`Raw` parts) would
    // incorrectly report the part missing.
    let workbook_bytes = part_xml_bytes(package, &workbook_path)?;
    let workbook_xml = std::str::from_utf8(&workbook_bytes).map_err(|_| Error::MissingWorkbook)?;
    let rid = workbook_sheet_rid(workbook_xml, sheet_name).ok_or(Error::MissingWorkbook)?;
    let rels_path = rels_path_for(&workbook_path);
    // Same promotion hazard as `workbook_bytes` above: the workbook `.rels`
    // part is promoted to a live `XmlTree` the moment an earlier edit's
    // `invalidate_calc_chain` removes a calc-chain `Relationship` from it --
    // at which point a bare `Package::part_bytes` (raw-only) would
    // incorrectly report it missing even though it's still fully present.
    let rels_bytes = part_xml_bytes(package, &rels_path)?;
    let rels_xml = std::str::from_utf8(&rels_bytes).map_err(|_| Error::MissingWorkbook)?;
    let rels = crate::xlsx::parse_rels(rels_xml);
    let target = rels.get(&rid).ok_or(Error::MissingWorkbook)?;
    Ok(normalize_part_target(&workbook_path, target))
}

/// `path`'s XML bytes regardless of whether the part is still `Raw` or has
/// already been promoted to an edited [`XmlTree`] this session (serializing
/// the tree on demand in that case). Needed anywhere a part might have been
/// promoted by an *earlier* edit in the same session -- at which point
/// [`Package::part_bytes`] (which only sees still-`Raw` parts) would
/// incorrectly report it missing even though it's very much present.
fn part_xml_bytes(package: &Package, path: &str) -> Result<Vec<u8>> {
    if let Some(bytes) = package.part_bytes(path) {
        return Ok(bytes.to_vec());
    }
    if let Some(tree) = package.part_tree_ref(path) {
        return Ok(tree.serialize());
    }
    Err(Error::MissingWorkbook)
}

fn workbook_path(package: &Package) -> String {
    let Some(root_rels) = package
        .part_bytes("_rels/.rels")
        .and_then(|bytes| std::str::from_utf8(bytes).ok())
    else {
        return "xl/workbook.xml".to_string();
    };
    let rels = crate::xlsx::parse_rels(root_rels);
    let types = crate::xlsx::parse_rel_types(root_rels);
    types
        .into_iter()
        .find_map(|(id, ty)| {
            (ty.rsplit('/').next() == Some("officeDocument"))
                .then(|| rels.get(&id).map(|target| canonical_part_name(target)))
                .flatten()
        })
        .unwrap_or_else(|| "xl/workbook.xml".to_string())
}

fn workbook_sheet_rid(xml: &str, sheet_name: &str) -> Option<String> {
    if !crate::xml_reference_work_within_budget(xml) {
        return None;
    }
    let mut reader = Reader::from_str(xml);
    loop {
        match reader.read_event() {
            Ok(Event::Start(e)) | Ok(Event::Empty(e))
                if local(e.name().as_ref()) == b"sheet"
                    && attr(&e, b"name").as_deref() == Some(sheet_name) =>
            {
                return attr(&e, b"id");
            }
            Ok(Event::Eof) => return None,
            Err(_) => return None,
            _ => {}
        }
    }
}

// --- SpreadsheetML tree finders/builders (cell-editing path) ---
//
// The functions below are the SpreadsheetML-specific layer above the
// format-agnostic `XmlTree`: they know what a `<row>`/`<c>` looks like and
// how a `Cell` value encodes onto one, but all structural mutation goes
// through `XmlTree`'s generic node operations. Because there is no manual
// `>`-scanning anywhere in this layer -- `XmlTree::parse` already rejects
// malformed/adversarial XML up front, never panics -- the whole "quoted `>`
// after a multibyte character" bug class from the old string-splicing
// implementation cannot recur here by construction.

/// Find (or create) the target worksheet's `<sheetData>` element. A
/// worksheet missing it entirely (unusual, but the old string-splicing code
/// tolerated it) gets one appended as the last child of `<worksheet>`,
/// mirroring that same fallback.
fn sml_sheet_data(tree: &mut XmlTree) -> Result<NodeId> {
    let worksheet = tree
        .root_element()
        .ok_or(Error::Zip("worksheet XML is malformed"))?;
    if let Some(sheet_data) = tree.child_by_name(worksheet, b"sheetData") {
        return Ok(sheet_data);
    }
    let idx = tree.children_of(worksheet).len();
    tree.insert_fragment_at(worksheet, idx, b"<sheetData></sheetData>")
}

/// A `<row>` child's parsed `r=` (1-based row number), or `None` if absent or
/// non-numeric.
fn sml_row_ref(tree: &XmlTree, child: NodeId) -> Option<u32> {
    tree.attr_value(child, b"r")
        .and_then(|v| std::str::from_utf8(v).ok())
        .and_then(|s| s.parse::<u32>().ok())
}

/// Find (or create, inserted in ascending `r=` order) the `<row r="N">`
/// child of `sheet_data` for 0-based `row`.
///
/// Two separate passes, deliberately not fused into one early-exiting scan:
/// `XmlTree::parse` is schema-agnostic and does not enforce ascending `r=`
/// order, so a worksheet with out-of-order rows (valid XML, just not Excel's
/// usual convention) must not have an existing row missed merely because a
/// higher-numbered row happens to appear earlier in document order.
fn sml_row_node(tree: &mut XmlTree, sheet_data: NodeId, row: u32) -> Result<NodeId> {
    let row_ref = row + 1;
    // Pass 1: full linear scan for an EXACT match across ALL children -- no
    // early break, so it cannot miss an out-of-order sibling.
    for &child in tree.children_of(sheet_data) {
        if sml_row_ref(tree, child) == Some(row_ref) {
            return Ok(child);
        }
    }
    // Pass 2 (only reached when no exact match exists): compute the
    // ascending-order insertion index for a NEW row. Early-breaking on the
    // first larger `r=` is safe here -- this pass only locates where a new
    // element belongs, it no longer needs to detect an existing match.
    let mut insert_idx = tree.children_of(sheet_data).len();
    for (i, &child) in tree.children_of(sheet_data).iter().enumerate() {
        if sml_row_ref(tree, child).is_some_and(|r| r > row_ref) {
            insert_idx = i;
            break;
        }
    }
    let frag = format!(r#"<row r="{row_ref}"></row>"#);
    tree.insert_fragment_at(sheet_data, insert_idx, frag.as_bytes())
}

/// Find (or create, inserted in ascending column order) the `<c r="A1">`
/// child of `row_node` for 0-based `(row, col)`. A newly created cell carries
/// only its `r` attribute -- no `s` (style), matching the old create-path
/// behavior of `inline_cell_xml(.., style: None)`; an existing cell's `s` (or
/// any other attribute this module doesn't know about) is left untouched by
/// construction, since it's never rebuilt from scratch.
///
/// Same two-pass shape as [`sml_row_node`] and for the same reason: a `<row>`
/// with out-of-order `<c>` children (valid XML, non-conforming OOXML) must
/// not have an existing cell missed by an early-break scan.
fn sml_cell_node(tree: &mut XmlTree, row_node: NodeId, row: u32, col: u16) -> Result<NodeId> {
    let cell_ref = a1(row, col);
    // Pass 1: full linear scan for an EXACT match across ALL children -- no
    // early break, so it cannot miss an out-of-order sibling.
    for &child in tree.children_of(row_node) {
        if tree.attr_value(child, b"r") == Some(cell_ref.as_bytes()) {
            return Ok(child);
        }
    }
    // Pass 2 (only reached when no exact match exists): compute the
    // ascending-column insertion index for a NEW cell. Early-breaking on the
    // first larger column is safe here -- this pass only locates where a new
    // element belongs, it no longer needs to detect an existing match.
    let mut insert_idx = tree.children_of(row_node).len();
    for (i, &child) in tree.children_of(row_node).iter().enumerate() {
        let Some(r) = tree.attr_value(child, b"r") else {
            continue;
        };
        if let Some(existing_col) = sml_col_of_ref(r) {
            if existing_col > u32::from(col) {
                insert_idx = i;
                break;
            }
        }
    }
    let frag = format!(r#"<c r="{cell_ref}"></c>"#);
    tree.insert_fragment_at(row_node, insert_idx, frag.as_bytes())
}

/// Parse a `<c r="...">` reference's leading column letters into a 0-based
/// column number, for insertion-order comparisons only: a malformed/absent
/// column just returns `None` (such a sibling is left in its current
/// position, never causing a panic or a wrong-but-crashing comparison).
fn sml_col_of_ref(r: &[u8]) -> Option<u32> {
    let mut col: u32 = 0;
    for &b in r {
        if b.is_ascii_alphabetic() {
            col = col
                .checked_mul(26)?
                .checked_add(u32::from(b.to_ascii_uppercase() - b'A') + 1)?;
        } else {
            break;
        }
    }
    col.checked_sub(1)
}

/// Whether -- and how -- `sml_set_cell_value` must change `cell`'s `t`
/// attribute for a given value. Decided up front (before any mutation) so it
/// can be preflighted via [`XmlTree::can_set_attr`].
#[derive(Clone, Copy)]
enum CellTypeAttr {
    Set(&'static [u8]),
    Remove,
}

/// Apply `value`'s SpreadsheetML encoding onto `cell` -- ports
/// `inline_cell_xml`'s value-encoding decisions (text -> inline string,
/// number/date -> plain `<v>`, bool -> `t="b"`, error -> `t="e"`, formula ->
/// `<f>` plus a cached `<v>` typed from the cached value's shape) onto tree
/// mutation. Only ever touches the value-carrying `t` attribute and the
/// `<v>`/`<f>`/`<is>` children: an existing `s` (style) attribute -- or any
/// other attribute/child this function doesn't model -- rides along
/// untouched, because the `<c>` tag is never rebuilt from scratch.
///
/// Every fallible step (the attribute write's budget, and the value
/// fragment's node budget) is preflighted BEFORE the old `<v>`/`<f>`/`<is>`
/// child is removed, so an `Err` return always means "nothing changed" --
/// never "old value gone, new value never written." This mirrors the
/// canonical edit recipe's "preflight on a throwaway parse" + "budget
/// preflight" steps: `XmlTree::insert_fragment_at` itself first parses the
/// fragment, then checks the combined node count against the budget before
/// committing anything, so redoing that same check here first (with the
/// tree untouched) is exact, not approximate -- `XmlTree::remove_child` only
/// ever shrinks a parent's child list, never the arena `node_count()` counts
/// against the budget, and neither it nor `remove_attr` can change whether
/// `can_set_attr` would answer differently later.
fn sml_set_cell_value(tree: &mut XmlTree, cell: NodeId, value: &Cell) -> Result<()> {
    let (type_attr, frag): (CellTypeAttr, String) = match value {
        // ponytail: edited text uses inline strings; rewrite sharedStrings when
        // SST index preservation becomes necessary.
        Cell::Text(t) => (
            CellTypeAttr::Set(b"inlineStr"),
            format!(r#"<is><t xml:space="preserve">{}</t></is>"#, esc_text(t)),
        ),
        Cell::Number(n) | Cell::Date(n) => {
            (CellTypeAttr::Remove, format!("<v>{}</v>", num_str(*n)))
        }
        Cell::Bool(b) => (
            CellTypeAttr::Set(b"b"),
            format!("<v>{}</v>", if *b { 1 } else { 0 }),
        ),
        Cell::Error(e) => (CellTypeAttr::Set(b"e"), format!("<v>{}</v>", esc_text(e))),
        Cell::Formula { formula, cached } => {
            let (t_attr, v): (Option<&'static [u8]>, String) = match cached.as_ref() {
                Cell::Text(t) => (Some(b"str"), esc_text(t)),
                Cell::Bool(b) => (Some(b"b"), if *b { "1" } else { "0" }.to_string()),
                Cell::Error(e) => (Some(b"e"), esc_text(e)),
                Cell::Number(n) | Cell::Date(n) => (None, num_str(*n)),
                Cell::Formula { .. } => (None, "0".to_string()),
            };
            let type_attr = match t_attr {
                Some(t) => CellTypeAttr::Set(t),
                None => CellTypeAttr::Remove,
            };
            (type_attr, format!("<f>{}</f><v>{v}</v>", esc_text(formula)))
        }
    };

    // Preflight 1: the value fragment must fit under the node budget. Parse
    // it as a throwaway tree (exactly what `insert_fragment_at` does
    // internally) and compare against `tree`'s CURRENT node count -- valid
    // both now and after the upcoming `remove_child` calls, since removal
    // never shrinks `node_count()`.
    let frag_tree = XmlTree::parse(frag.as_bytes())?;
    if tree.node_count().saturating_add(frag_tree.node_count()) > crate::xmltree::node_budget() {
        return Err(Error::Xml("edit would exceed the node budget"));
    }
    // Preflight 2: a new `t` attribute value must fit under the attribute
    // budget (replacing an existing `t` always succeeds, so this only
    // rejects the "adding a brand-new attribute" case).
    if let CellTypeAttr::Set(_) = type_attr {
        if !tree.can_set_attr(cell, b"t") {
            return Err(Error::Xml("element has too many attributes to add another"));
        }
    }

    // Both preflights passed: it is now safe to drop the old value before
    // writing the new one.
    for name in [b"v".as_slice(), b"f".as_slice(), b"is".as_slice()] {
        if let Some(child) = tree.child_by_name(cell, name) {
            tree.remove_child(cell, child)?;
        }
    }
    match type_attr {
        CellTypeAttr::Set(val) => tree.set_attr(cell, b"t", val)?,
        CellTypeAttr::Remove => tree.remove_attr(cell, b"t"),
    }
    let idx = tree.children_of(cell).len();
    tree.insert_fragment_at(cell, idx, frag.as_bytes())?;
    Ok(())
}

/// Find-or-create the `<c>` for 0-based `(row, col)` in `tree`'s worksheet
/// and apply `value`'s encoding to it. The single entry point
/// `set_cell_value`/`append_row` both drive.
fn sml_edit_cell(tree: &mut XmlTree, row: u32, col: u16, value: &Cell) -> Result<()> {
    let sheet_data = sml_sheet_data(tree)?;
    let row_node = sml_row_node(tree, sheet_data, row)?;
    let cell = sml_cell_node(tree, row_node, row, col)?;
    sml_set_cell_value(tree, cell, value)
}

/// Remove the `<c>` for 0-based `(row, col)` entirely (not just its value),
/// if present -- a no-op if the row or cell doesn't exist. Mirrors the old
/// string-splicing `clear_range`'s `find_cell_bounds` + whole-span removal.
fn sml_clear_cell(tree: &mut XmlTree, row: u32, col: u16) -> Result<()> {
    let Some(worksheet) = tree.root_element() else {
        return Ok(());
    };
    let Some(sheet_data) = tree.child_by_name(worksheet, b"sheetData") else {
        return Ok(());
    };
    let row_ref = row + 1;
    let row_node = tree.children_of(sheet_data).iter().copied().find(|&c| {
        tree.attr_value(c, b"r")
            .and_then(|v| std::str::from_utf8(v).ok())
            .and_then(|s| s.parse::<u32>().ok())
            == Some(row_ref)
    });
    let Some(row_node) = row_node else {
        return Ok(());
    };
    let cell_ref = a1(row, col);
    let cell_node = tree
        .children_of(row_node)
        .iter()
        .copied()
        .find(|&c| tree.attr_value(c, b"r") == Some(cell_ref.as_bytes()));
    let Some(cell_node) = cell_node else {
        return Ok(());
    };
    tree.remove_child(row_node, cell_node)
}

/// The 0-based row `append_row` should target: one past the highest existing
/// `<row r=N>` under `<sheetData>` (0 if the sheet has no rows yet).
fn sml_next_append_row(tree: &XmlTree) -> u32 {
    let Some(worksheet) = tree.root_element() else {
        return 0;
    };
    let Some(sheet_data) = tree.child_by_name(worksheet, b"sheetData") else {
        return 0;
    };
    tree.children_of(sheet_data)
        .iter()
        .filter_map(|&c| tree.attr_value(c, b"r"))
        .filter_map(|r| std::str::from_utf8(r).ok())
        .filter_map(|s| s.parse::<u32>().ok())
        .max()
        .unwrap_or(0)
}

/// Read `path`'s tree without promoting an unpromoted part: reuses the
/// already-promoted tree if `path` was already promoted this session (so an
/// earlier edit in the same session is visible to the peek), else parses a
/// throwaway `XmlTree` from its raw bytes; `missing` is returned verbatim if
/// the part doesn't exist at all. Lets a caller validate something (e.g. the
/// next append row, a sheet-name uniqueness/visibility invariant, bounds --
/// checked *before* any mutation) without leaving a spurious
/// `touched`/re-serialized part behind if the validation then fails.
fn peek_part_tree<T>(
    package: &Package,
    path: &str,
    missing: Error,
    read: impl FnOnce(&XmlTree) -> Result<T>,
) -> Result<T> {
    if let Some(tree) = package.part_tree_ref(path) {
        return read(tree);
    }
    let bytes = package.part_bytes(path).ok_or(missing)?;
    let tree = XmlTree::parse(bytes)?;
    read(&tree)
}

/// The child of `root` whose `attr` attribute, read as UTF-8, satisfies
/// `resolve` -- used to locate a `[Content_Types].xml` `<Override>` or
/// `.rels` `<Relationship>` by exact resolved-target equality (never a
/// substring match).
fn find_child_with_attr(
    tree: &XmlTree,
    root: NodeId,
    attr: &[u8],
    resolve: &dyn Fn(&str) -> bool,
) -> Option<NodeId> {
    tree.children_of(root).iter().copied().find(|&c| {
        tree.attr_value(c, attr)
            .and_then(|v| std::str::from_utf8(v).ok())
            .is_some_and(resolve)
    })
}

/// Read-only peek: does `part` (already-promoted tree, or a throwaway parse
/// of its raw bytes) have a child matching `find_child_with_attr`? Absent
/// parts, or parts whose bytes fail to parse, report `false` rather than
/// erroring -- matching the old code's `if let Some(xml) = ... { .. }`
/// skip-if-missing behavior.
fn has_child_with_attr(
    package: &Package,
    part: &str,
    attr: &[u8],
    resolve: &dyn Fn(&str) -> bool,
) -> bool {
    if let Some(tree) = package.part_tree_ref(part) {
        return match tree.root_element() {
            Some(root) => find_child_with_attr(tree, root, attr, resolve).is_some(),
            None => false,
        };
    }
    let Some(bytes) = package.part_bytes(part) else {
        return false;
    };
    let Ok(tree) = XmlTree::parse(bytes) else {
        return false;
    };
    match tree.root_element() {
        Some(root) => find_child_with_attr(&tree, root, attr, resolve).is_some(),
        None => false,
    }
}

/// Remove the child of `part`'s root element whose `attr` attribute resolves
/// to a match under `resolve` (exact equality, never substring), returning
/// the touched part name on an actual removal. A cheap read-only peek
/// ([`has_child_with_attr`]) runs first so a part with nothing to remove is
/// never promoted (and thus never re-serialized/marked touched) -- only a
/// genuine match promotes `part` via [`Package::part_tree_mut`].
fn remove_matching_child(
    package: &mut Package,
    part: &str,
    attr: &[u8],
    resolve: &dyn Fn(&str) -> bool,
) -> Result<Option<String>> {
    if !has_child_with_attr(package, part, attr, resolve) {
        return Ok(None);
    }
    let before = package.touched_parts();
    let tree = package.part_tree_mut(part)?;
    let Some(root) = tree.root_element() else {
        return Ok(None);
    };
    let Some(node) = find_child_with_attr(tree, root, attr, resolve) else {
        return Ok(None);
    };
    tree.remove_child(root, node)?;
    Ok(newly_touched(&before, package).into_iter().next())
}

/// Parts in `package.touched_parts()` now that weren't in `before` -- used to
/// recover the actual canonical stored key a `part_tree_mut` call just
/// touched (its resolved key may differ in case/leading-slash form from the
/// name it was looked up with), mirroring how `replace_part`'s return value
/// used to be recorded directly.
fn newly_touched(before: &[String], package: &Package) -> Vec<String> {
    package
        .touched_parts()
        .into_iter()
        .filter(|n| !before.contains(n))
        .collect()
}

fn rels_path_for(path: &str) -> String {
    match path.rfind('/') {
        Some(i) => format!("{}/_rels/{}.rels", &path[..i], &path[i + 1..]),
        None => format!("_rels/{path}.rels"),
    }
}

fn normalize_part_target(base: &str, target: &str) -> String {
    let target = target.replace('\\', "/");
    if let Some(abs) = target.strip_prefix('/') {
        return abs.to_string();
    }
    let mut parts: Vec<&str> = base
        .rsplit_once('/')
        .map(|(dir, _)| dir.split('/').collect())
        .unwrap_or_default();
    for segment in target.split('/') {
        match segment {
            "" | "." => {}
            ".." => {
                parts.pop();
            }
            _ => parts.push(segment),
        }
    }
    parts.join("/")
}

fn canonical_part_name(name: &str) -> String {
    name.replace('\\', "/").trim_start_matches('/').to_string()
}

fn attr(e: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option<String> {
    e.attributes().flatten().find_map(|a| {
        (local(a.key.as_ref()) == key).then(|| {
            a.decoded_and_normalized_value_with(
                XmlVersion::Implicit1_0,
                e.decoder(),
                1,
                quick_xml::escape::resolve_xml_entity,
            )
            .ok()
            .map(|value| value.into_owned())
            .unwrap_or_default()
        })
    })
}

fn local(name: &[u8]) -> &[u8] {
    name.rsplit(|&b| b == b':').next().unwrap_or(name)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::xmltree::{reset_test_node_budget, set_test_node_budget};

    /// A minimal worksheet part with exactly one row, one valued cell.
    /// Shared by the narrow (`sml_set_cell_value`) and broad
    /// (`Spreadsheet::set_cell_value`) node-budget regression tests below, so
    /// the pinned budget and the fixture can never drift out of sync.
    const MINIMAL_WORKSHEET_XML: &[u8] = br#"<?xml version="1.0" encoding="UTF-8"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>"#;

    /// Regression test for a `sml_set_cell_value` bug: it unconditionally
    /// removed the cell's existing `<v>`/`<f>`/`<is>` child FIRST, and only
    /// afterward performed the fallible write of the new value (`set_attr`
    /// and/or `insert_fragment_at`, either of which can fail under node/attr
    /// budget pressure). A failure at that point returned `Err` with the old
    /// value already gone and the new value never written -- silent data
    /// loss reported as "nothing happened."
    ///
    /// This is the narrowest possible reproduction: it calls
    /// `sml_set_cell_value` directly on a tree pinned to exactly its current
    /// node count (zero room for even one more node), so the value-insert
    /// step is guaranteed to fail.
    #[test]
    fn sml_set_cell_value_leaves_original_value_intact_when_node_budget_write_fails() {
        let mut tree = XmlTree::parse(MINIMAL_WORKSHEET_XML).expect("parse minimal worksheet");
        let budget = tree.node_count();
        let worksheet = tree.root_element().expect("root element");
        let sheet_data = tree
            .child_by_name(worksheet, b"sheetData")
            .expect("sheetData");
        let row = tree.child_by_name(sheet_data, b"row").expect("row");
        let cell = tree.child_by_name(row, b"c").expect("cell");

        set_test_node_budget(budget);
        let result = sml_set_cell_value(&mut tree, cell, &Cell::Number(999.0));
        reset_test_node_budget();

        assert!(
            result.is_err(),
            "overwriting the value must fail under a zero-room node budget"
        );
        let v = tree
            .child_by_name(cell, b"v")
            .expect("the ORIGINAL <v> child must survive a failed write");
        assert_eq!(tree.text_of(v), "1", "original value must be untouched");
        assert!(
            tree.child_by_name(cell, b"f").is_none(),
            "no half-written <f> child should appear"
        );
        assert!(
            tree.child_by_name(cell, b"is").is_none(),
            "no half-written <is> child should appear"
        );
        assert_eq!(
            tree.serialize(),
            XmlTree::parse(MINIMAL_WORKSHEET_XML)
                .expect("re-parse fixture")
                .serialize(),
            "tree must be byte-for-byte unchanged after a failed write"
        );
    }

    /// Builds a minimal single-sheet `.xlsx` ZIP whose `xl/worksheets/sheet1.xml`
    /// is exactly [`MINIMAL_WORKSHEET_XML`], for the broader
    /// `Spreadsheet::set_cell_value` end-to-end regression test below.
    fn minimal_xlsx_with_one_valued_cell() -> Vec<u8> {
        use std::io::Write;
        use zip::write::SimpleFileOptions;

        fn add(
            zip: &mut zip::ZipWriter<std::io::Cursor<Vec<u8>>>,
            opt: SimpleFileOptions,
            name: &str,
            bytes: &[u8],
        ) {
            zip.start_file(name, opt).unwrap();
            zip.write_all(bytes).unwrap();
        }

        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
        let opt = SimpleFileOptions::default();
        add(
            &mut zip,
            opt,
            "[Content_Types].xml",
            br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>"#,
        );
        add(
            &mut zip,
            opt,
            "_rels/.rels",
            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#,
        );
        add(
            &mut zip,
            opt,
            "xl/workbook.xml",
            br#"<?xml version="1.0" encoding="UTF-8"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets></workbook>"#,
        );
        add(
            &mut zip,
            opt,
            "xl/_rels/workbook.xml.rels",
            br#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#,
        );
        add(
            &mut zip,
            opt,
            "xl/worksheets/sheet1.xml",
            MINIMAL_WORKSHEET_XML,
        );
        zip.finish().unwrap().into_inner()
    }

    /// Broader end-to-end confirmation through the public API: the same
    /// budget-failure scenario, driven through `Spreadsheet::set_cell_value`,
    /// must report `Err` while leaving the cell's original value intact and
    /// `edited_parts()` empty (the `?` in `set_cell_value` must short-circuit
    /// before the edited-parts bookkeeping runs).
    #[test]
    fn set_cell_value_leaves_cell_untouched_when_node_budget_write_fails() {
        let input = minimal_xlsx_with_one_valued_cell();
        let budget = XmlTree::parse(MINIMAL_WORKSHEET_XML)
            .expect("parse fixture")
            .node_count();

        set_test_node_budget(budget);
        let mut spreadsheet = Spreadsheet::open(&input).expect("open editable xlsx");
        let result = spreadsheet.set_cell_value("Data", 0, 0, Cell::Number(999.0));
        reset_test_node_budget();

        assert!(
            result.is_err(),
            "write must fail under a zero-room node budget"
        );
        assert!(
            spreadsheet.edited_parts().is_empty(),
            "a failed edit must not be recorded as an edited part"
        );

        let saved = spreadsheet.save().expect("save must still succeed");
        let reopened = Workbook::open(&saved).expect("reopen saved package");
        assert_eq!(
            reopened.sheet_by_name("Data").and_then(|s| s.cell(0, 0)),
            Some(&Cell::Number(1.0)),
            "original cell value must survive a failed edit"
        );
    }
}