zenwebp 0.4.5

High-performance WebP encoding and decoding in pure Rust
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
//! Type-safe encoder configuration API.
//!
//! This module provides separate configuration types for lossy and lossless
//! encoding, ensuring compile-time prevention of invalid parameter combinations.
//!
//! # Primary API (Recommended)
//!
//! Use concrete types directly:
//!
//! ```rust
//! use zenwebp::{LossyConfig, EncodeRequest, PixelLayout};
//!
//! let config = LossyConfig::new()
//!     .with_quality(75.0)
//!     .with_sns_strength(50);
//!
//! let pixels = vec![0u8; 64 * 64 * 4];
//! let webp = EncodeRequest::lossy(&config, &pixels, PixelLayout::Rgba8, 64, 64)
//!     .encode()?;
//! # Ok::<(), whereat::At<zenwebp::EncodeError>>(())
//! ```
//!
//! # Runtime Mode Selection
//!
//! When you need to choose the mode at runtime, use the enum wrapper:
//!
//! ```rust
//! use zenwebp::{EncoderConfig, LossyConfig, LosslessConfig};
//!
//! fn get_config(has_transparency: bool) -> EncoderConfig {
//!     if has_transparency {
//!         EncoderConfig::Lossless(LosslessConfig::new())
//!     } else {
//!         EncoderConfig::Lossy(LossyConfig::new())
//!     }
//! }
//! ```

use crate::{Limits, Preset};

/// How `InternalParams::sharp_yuv` should be applied. Expert-only.
///
/// `Off` / `On` mirror the boolean form; `Custom(_)` lets the caller
/// pass a tuned `zenyuv::SharpYuvConfig` (e.g., a non-default chroma
/// downsample matrix). `None` on `InternalParams::sharp_yuv` means the
/// default `LossyConfig::sharp_yuv` is unchanged.
#[cfg(feature = "__expert")]
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum SharpYuvSetting {
    Off,
    On,
    Custom(zenyuv::SharpYuvConfig),
}

/// Bundle of advanced encoder tuning knobs. Expert-only.
///
/// Intended for codec calibration sweeps and the picker training
/// pipeline — production codec consumers should rely on `Preset` +
/// `Quality` + `Method` and let zenwebp pick reasonable defaults for
/// the rest. Only build with the `expert` cargo feature when you
/// actually need to vary these axes.
///
/// Every field is optional; `None` means "leave the
/// `LossyConfig`'s existing value alone." Call
/// [`LossyConfig::with_internal_params`] to apply the bundle.
///
/// ```ignore
/// use zenwebp::{LossyConfig, InternalParams};
/// let cfg = LossyConfig::new()
///     .with_quality(75.0)
///     .with_internal_params(InternalParams {
///         partition_limit: Some(50),
///         multi_pass_stats: Some(true),
///         ..Default::default()
///     });
/// ```
#[cfg(feature = "__expert")]
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct InternalParams {
    /// **Pipeline stage:** intra mode decision (luma 16×16 vs 4×4 selection),
    /// `vp8/mode_selection.rs:1846,1939–1953`.
    ///
    /// **Why override:** the default `None` lets the encoder retry-with-bump
    /// on partition-0 overflow (large images at high quality where I4 metadata
    /// blows past VP8's 512 KB partition-0 ceiling). Two cases want an explicit
    /// value: (a) calibration sweeps that need a fixed mode-decision regime so
    /// results are reproducible across runs, and (b) very large images where
    /// you'd rather pre-commit to I16-only than pay the retry cost.
    ///
    /// **Mechanism:** the encoder turns `partition_limit` into an additive
    /// skip-threshold boost in mode selection — at `0` (default) the I4 vs
    /// I16 decision uses the base distortion threshold (≈211); at `80` the
    /// threshold is roughly 5× higher, suppressing most I4 attempts; at
    /// `>=100` the encoder forces I16-only and skips intra-4×4 entirely.
    /// I4 carries fully-coded sub-block prediction modes in partition 0,
    /// so suppressing it shrinks partition 0 at a cost in coding efficiency.
    /// Range clamped to `0..=100` by [`LossyConfig::with_partition_limit`].
    ///
    /// **Default-derivation:** `None` ⇒ the encoder runs in **automatic
    /// retry mode** — first attempt at limit 0, then 30, 60, 100 on
    /// partition-0 overflow (`vp8/mod.rs:2237–2255`). `Some(v)` disables
    /// the retry loop and pins the limit at `v`.
    ///
    /// See [`LossyConfig::with_partition_limit`].
    pub partition_limit: Option<u8>,
    /// **Pipeline stage:** outer encode driver — wraps the full mode
    /// decision + transform + quantize + token-record pass
    /// (`vp8/mod.rs:1080–1152`).
    ///
    /// **Why override:** zenwebp's default of one pass is tuned for
    /// single-shot encodes where ~0.1 % size is not worth doubling
    /// encode time. Override `Some(true)` when (a) running a
    /// `target_size` / `target_zensim` search loop where the marginal
    /// size win amortizes across multiple inner probes, or (b) bench-
    /// matching libwebp at `cost_model = StrictLibwebpParity` (libwebp
    /// runs `config->pass = 1` extra pass by default).
    ///
    /// **Mechanism:** when set and `method == 4`, the encoder runs the
    /// full encode twice. The first pass collects token statistics into
    /// `proba_stats`; between passes it rebuilds `level_costs` from the
    /// observed token distribution so the second pass's mode selection
    /// and (m4) trellis use empirical, image-tuned costs rather than
    /// the static prior. The second pass emits the actual bitstream.
    /// At m5/m6 trellis already image-adapts via per-pass `proba_stats`
    /// accumulation, so the second pass measurably regresses size on
    /// CID22 — the flag is gated to m4 only (`vp8/mod.rs:1103`) and
    /// silently ignored at other tiers.
    ///
    /// **Default-derivation:** `None` ⇒ `false` (single pass — zenwebp's
    /// speed/size default). Roughly **doubles** encode time at m4 in
    /// exchange for ~0.1 % size win on photo content.
    ///
    /// See [`LossyConfig::with_multi_pass_stats`].
    pub multi_pass_stats: Option<bool>,
    /// **Pipeline stage:** segmentation analysis, between k-means
    /// segment assignment and per-segment quantizer setup
    /// (`vp8/mod.rs:1715–1721`, `analysis/segment.rs:194`).
    ///
    /// **Why override:** k-means on per-MB activity produces a noisy
    /// segment map for images with fine-grained texture variation
    /// (line art, foliage, screen content with mixed regions). The
    /// noise wastes bits on segment-id changes between neighbors and
    /// can cause visible quantizer-boundary banding. Two override
    /// cases: (a) preprocessing-equivalence runs vs `cwebp -pre 1`,
    /// and (b) noisy-segmentation content where the per-MB segment-id
    /// signaling cost outweighs the per-segment quantizer fit.
    ///
    /// **Mechanism:** when on, the encoder applies a 3×3 majority
    /// filter to the per-MB segment map after assignment: any block
    /// where ≥5 of 8 neighbors share a segment id is reassigned to
    /// that majority segment. Borders are not modified. This produces
    /// fewer but larger contiguous segment regions, which both reduces
    /// the entropy cost of the segment-id stream and avoids
    /// quantizer-step artifacts at noisy boundaries.
    ///
    /// **Default-derivation:** `None` ⇒ `false` — matches libwebp's
    /// `config->preprocessing & 1` default (off). Equivalent to
    /// `cwebp -pre 1` when on. The smoothing only applies when
    /// `num_segments > 1`; with one segment there's nothing to smooth.
    ///
    /// See [`LossyConfig::with_smooth_segment_map`].
    pub smooth_segment_map: Option<bool>,
    /// **Pipeline stage:** RGB → YUV 4:2:0 conversion, before any VP8
    /// encoding work (`vp8/mod.rs:1002`,
    /// `decoder/yuv.rs:716` `convert_image_sharp_yuv_with_config`).
    ///
    /// **Why override:** standard 4:2:0 chroma downsampling
    /// (`AverageBoxFilter`) is fast but loses high-frequency chroma
    /// detail visible on saturated edges (red text on white, sharp
    /// color transitions in graphics). Override cases: (a) photos
    /// with saturated detail where the default produces visible
    /// chroma bleed, and (b) calibration grids that compare baseline
    /// vs sharp-yuv to measure the perceptual win. `Custom(_)` is
    /// for sweeping non-default chroma matrices in the picker
    /// training pipeline.
    ///
    /// **Mechanism:** sharp-YUV iteratively refines the downsampled
    /// chroma planes by minimizing the error between the upsampled
    /// reconstruction and the source's true chroma. `Off` uses the
    /// LossyConfig default (no sharp-yuv). `On` enables iterative
    /// refinement with `zenyuv::SharpYuvConfig::default()` (BT.709
    /// matrix, default iteration count). `Custom(cfg)` substitutes a
    /// caller-supplied config — for example, a non-default
    /// chroma-downsample kernel or different colorimetry.
    ///
    /// **Default-derivation:** `None` on `InternalParams::sharp_yuv`
    /// leaves `LossyConfig::sharp_yuv` unchanged. The
    /// `LossyConfig::new()` default is `None` (standard chroma
    /// downsampling). `Preset::Photo` does **not** auto-enable sharp
    /// YUV in `LossyConfig::to_params` — it must be set explicitly.
    pub sharp_yuv: Option<SharpYuvSetting>,
    /// **Pipeline stage:** mode selection rate-distortion costs and
    /// (at m5+) trellis quantization (`encoder/psy.rs:152–206`,
    /// `analysis/mod.rs:222–243`).
    ///
    /// **Why override:** zenwebp's default cost model layers three
    /// perceptual extensions on top of libwebp's algorithm, tuned
    /// against butteraugli / SSIMULACRA2. Two cases want
    /// `StrictLibwebpParity`: (a) bit-comparing zenwebp's output
    /// against libwebp at the same `(quality, method, sns, filter,
    /// segments)` tuple to isolate algorithmic-parity bugs from
    /// perceptual-extension behavior, and (b) workloads where
    /// libwebp's size/PSNR tradeoff is the explicit target (legacy
    /// pipelines, regression baselines).
    ///
    /// **Mechanism — what each extension changes** (when
    /// `ZenwebpDefault`, gated by method):
    ///
    /// - **PSY_WEIGHT_Y CSF table** (m3+): replaces libwebp's
    ///   `kWeightY` with an enhanced contrast-sensitivity-function
    ///   table that has a steeper high-frequency rolloff. The table
    ///   feeds into TDisto (transformed distortion), so the cost of
    ///   keeping high-freq luma coefficients goes up — mode selection
    ///   prefers smoother reconstructions where the eye won't notice.
    /// - **SATD masking-alpha blend** (m4+, gated by `sns_strength > 0`):
    ///   blends the per-MB DCT alpha with a SATD-derived
    ///   masking-alpha so segmentation places highly-textured blocks
    ///   into segments with coarser quantizers (texture masks
    ///   quantization noise). `analysis/mod.rs:239–243` skips the
    ///   blend under `StrictLibwebpParity`.
    /// - **JND coefficient zeroing** (m5+): scales per-position
    ///   just-noticeable-difference thresholds by quantizer and zeros
    ///   any DCT coefficient below threshold during trellis. Saves
    ///   bits on perceptually-invisible coefficients without harming
    ///   reconstruction quality. Under `StrictLibwebpParity`
    ///   `jnd_threshold_y/uv` stay at zero so no coefficients get
    ///   zeroed by this path.
    ///
    /// `StrictLibwebpParity` returns the default `PsyConfig` early
    /// (`psy.rs:161–163`), which means **all three extensions are
    /// disabled regardless of method**.
    ///
    /// **Default-derivation:** `None` on `InternalParams::cost_model`
    /// leaves `LossyConfig::cost_model` unchanged. The
    /// `LossyConfig::new()` default is `CostModel::ZenwebpDefault`.
    ///
    /// See [`LossyConfig::with_cost_model`].
    pub cost_model: Option<super::api::CostModel>,
}

/// Configuration for lossy (VP8) encoding.
///
/// Lossy encoding uses DCT-based compression similar to JPEG, with additional
/// features like loop filtering, spatial noise shaping, and segmentation.
#[derive(Clone)]
#[non_exhaustive]
pub struct LossyConfig {
    /// Encoding quality (0.0 = smallest, 100.0 = best). Default: 75.0.
    pub quality: f32,
    /// Quality/speed tradeoff (0 = fast, 6 = slower but better). Default: 4.
    pub method: u8,
    /// Alpha channel quality (0-100, 100 = lossless alpha). Default: 100.
    pub alpha_quality: u8,
    /// Target file size in bytes (0 = disabled). Default: 0.
    pub target_size: u32,
    /// Target PSNR in dB (0.0 = disabled). Default: 0.0.
    pub target_psnr: f32,
    /// Target perceptual zensim score. `None` = disabled (default).
    /// `Some(t)` enables a closed-loop encode → decode → measure → adjust
    /// iteration that converges on `t`. See [`super::ZensimTarget`] for
    /// the full knob set and [`Self::with_target_zensim`] for the
    /// builder method (it accepts either a bare `f32` or a fully-built
    /// [`ZensimTarget`](super::ZensimTarget) via `Into`).
    ///
    /// Requires the `target-zensim` crate feature to actually iterate;
    /// otherwise the encoder falls back to the calibrated starting-q
    /// estimate for the bucket and ships that single pass.
    pub target_zensim: Option<super::zensim_target::ZensimTarget>,
    /// Content-aware preset (affects SNS, filter, sharpness). Default: None.
    pub preset: Option<Preset>,
    /// Sharp YUV configuration. `None` = disabled (standard chroma downsampling).
    /// `Some(config)` = iterative chroma refinement with given parameters.
    pub sharp_yuv: Option<zenyuv::SharpYuvConfig>,
    /// Spatial noise shaping strength (0-100). None = use preset default.
    pub sns_strength: Option<u8>,
    /// Loop filter strength (0-100). None = use preset default.
    pub filter_strength: Option<u8>,
    /// Loop filter sharpness (0-7). None = use preset default.
    pub filter_sharpness: Option<u8>,
    /// Number of segments (1-4). None = use preset default.
    pub segments: Option<u8>,
    /// Partition limit (0-100). Controls how aggressively the encoder avoids
    /// I4 prediction mode to prevent partition 0 overflow on very large images.
    /// 0 = no limit (default), 100 = maximum I4 suppression.
    /// None = automatic (encoder will retry with increasing limits on overflow).
    pub partition_limit: Option<u8>,
    /// Preprocessing options. Default: all off (matches libwebp's
    /// `config->preprocessing = 0`).
    pub smooth_segment_map: bool,
    /// Cost model selection. Default: `ZenwebpDefault` (perceptual extensions
    /// enabled per method level). Set to `StrictLibwebpParity` for libwebp
    /// algorithm parity (disables PSY_WEIGHT_Y, SATD masking blend, JND zeroing).
    pub cost_model: super::api::CostModel,
    /// Run a stat-collection encoder pass before the emit pass to refresh
    /// `level_costs` from the observed token distribution. Roughly **doubles**
    /// encode time at m4 in exchange for ~0.1% size win on photo content.
    /// libwebp does this by default; zenwebp keeps it OFF to optimize the
    /// speed/size tradeoff. Worth enabling for `target_size` / `target_zensim`
    /// search loops, or under `CostModel::StrictLibwebpParity`.
    /// Default `false`. Currently only m4 honors this flag — m5/m6 already
    /// saturate per-pass and multi-pass at those tiers regresses size.
    pub multi_pass_stats: bool,
    /// Resource limits for validation.
    pub limits: Limits,
    /// Crate-internal: per-segment additive quant_index offsets applied
    /// AFTER SNS modulation. Used by the `target_zensim` per-segment
    /// correction pass. `None` (default) leaves segments untouched, so
    /// public-API encodes never trigger this path.
    #[doc(hidden)]
    pub(crate) segment_quant_overrides: Option<[i8; 4]>,
}

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

impl LossyConfig {
    /// Create a new lossy encoder configuration with defaults.
    ///
    /// Default: quality 75, method 4, no preset overrides.
    #[must_use]
    pub fn new() -> Self {
        Self {
            quality: 75.0,
            method: 4,
            alpha_quality: 100,
            target_size: 0,
            target_psnr: 0.0,
            target_zensim: None,
            preset: None,
            sharp_yuv: None,
            sns_strength: None,
            filter_strength: None,
            filter_sharpness: None,
            segments: None,
            partition_limit: None,
            smooth_segment_map: false,
            cost_model: super::api::CostModel::ZenwebpDefault,
            multi_pass_stats: false,
            limits: Limits::none(),
            segment_quant_overrides: None,
        }
    }

    /// Create from a preset with the given quality.
    #[must_use]
    pub fn with_preset(preset: Preset, quality: f32) -> Self {
        Self {
            quality,
            preset: Some(preset),
            ..Self::new()
        }
    }

    /// Set encoding quality (0.0 = smallest file, 100.0 = best quality).
    #[must_use]
    pub fn with_quality(mut self, quality: f32) -> Self {
        self.quality = quality.clamp(0.0, 100.0);
        self
    }

    /// Set method (0 = fastest, 6 = slowest but best compression).
    #[must_use]
    pub fn with_method(mut self, method: u8) -> Self {
        self.method = method.min(6);
        self
    }

    /// Set alpha channel quality (0-100, 100 = lossless alpha).
    #[must_use]
    pub fn with_alpha_quality(mut self, quality: u8) -> Self {
        self.alpha_quality = quality.min(100);
        self
    }

    /// Target output file size in bytes (encoder will adjust quality).
    #[must_use]
    pub fn with_target_size(mut self, size: u32) -> Self {
        self.target_size = size;
        self
    }

    /// Target PSNR in dB (encoder will adjust quality).
    #[must_use]
    pub fn with_target_psnr(mut self, psnr: f32) -> Self {
        self.target_psnr = psnr;
        self
    }

    /// Target a perceptual zensim score (closed-loop adaptive encode).
    ///
    /// Accepts either a bare `f32` (uses
    /// [`ZensimTarget::new`](super::zensim_target::ZensimTarget::new)
    /// defaults — overshoot=1.5, undershoot=None, max_passes=2) or a
    /// fully-built [`ZensimTarget`](super::zensim_target::ZensimTarget)
    /// via `Into`:
    ///
    /// ```ignore
    /// use zenwebp::{LossyConfig, ZensimTarget};
    /// // Bare f32 — default tolerances / passes:
    /// let c = LossyConfig::new().with_target_zensim(80.0);
    /// // Full struct — custom max_passes:
    /// let c = LossyConfig::new()
    ///     .with_target_zensim(ZensimTarget::new(80.0).with_max_passes(3));
    /// ```
    ///
    /// Requires the `target-zensim` crate feature for the iteration to
    /// actually run; without it, the encoder ships the calibrated
    /// starting-q estimate as a single pass.
    ///
    /// **Supported pixel layouts:** `PixelLayout::Rgb8` and
    /// `PixelLayout::Rgba8`. RGBA inputs are encoded as alpha-bearing
    /// WebP and measured against the source via zensim's
    /// deterministic-noise compositing — the same composite is applied
    /// to source and reconstruction, so alpha quality is well-defined.
    /// Other layouts (BGR, BGRA, ARGB, L8, LA8, YUV420) with
    /// `target_zensim` set return
    /// [`EncodeError::TargetZensimUnsupportedLayout`](super::api::EncodeError::TargetZensimUnsupportedLayout).
    #[must_use]
    pub fn with_target_zensim<T: Into<super::zensim_target::ZensimTarget>>(
        mut self,
        target: T,
    ) -> Self {
        self.target_zensim = Some(target.into());
        self
    }

    /// Apply a content-aware preset (overrides SNS, filter, sharpness defaults).
    #[must_use]
    pub fn with_preset_value(mut self, preset: Preset) -> Self {
        self.preset = Some(preset);
        self
    }

    /// Enable or disable sharp YUV conversion. Expert-only.
    /// `true` enables with default config, `false` disables.
    ///
    /// **Gated behind the `expert` cargo feature.** Default consumers
    /// should use [`Preset::Photo`] (which enables sharp YUV via the
    /// preset's internal config); this setter is for codec calibration
    /// sweeps + the picker training pipeline. See
    /// [`LossyConfig::with_internal_params`].
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_sharp_yuv(mut self, enable: bool) -> Self {
        self.sharp_yuv = if enable {
            Some(zenyuv::SharpYuvConfig::default())
        } else {
            None
        };
        self
    }

    /// Enable sharp YUV with a custom configuration. Expert-only —
    /// see [`LossyConfig::with_internal_params`].
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_sharp_yuv_config(mut self, config: zenyuv::SharpYuvConfig) -> Self {
        self.sharp_yuv = Some(config);
        self
    }

    /// Override spatial noise shaping strength (0-100).
    /// Higher values preserve more texture detail.
    #[must_use]
    pub fn with_sns_strength(mut self, strength: u8) -> Self {
        self.sns_strength = Some(strength.min(100));
        self
    }

    /// Override loop filter strength (0-100).
    /// Higher values produce smoother output.
    #[must_use]
    pub fn with_filter_strength(mut self, strength: u8) -> Self {
        self.filter_strength = Some(strength.min(100));
        self
    }

    /// Override loop filter sharpness (0-7).
    #[must_use]
    pub fn with_filter_sharpness(mut self, sharpness: u8) -> Self {
        self.filter_sharpness = Some(sharpness.min(7));
        self
    }

    /// Override number of segments for adaptive quantization (1-4).
    #[must_use]
    pub fn with_segments(mut self, segments: u8) -> Self {
        self.segments = Some(segments.clamp(1, 4));
        self
    }

    /// Set partition limit to prevent partition 0 overflow on very large images.
    /// Expert-only — see [`LossyConfig::with_internal_params`].
    ///
    /// Range 0-100. Higher values more aggressively suppress I4 prediction mode,
    /// which uses more bits in partition 0. This trades quality for the ability to
    /// encode very large images without hitting the VP8 512KB partition 0 limit.
    ///
    /// - `0`: No limit (default behavior — encoder errors on overflow)
    /// - `30-70`: Recommended for moderately large images (25-60MP)
    /// - `100`: Maximum suppression (nearly all I16 mode)
    ///
    /// When not set (`None`), the encoder automatically retries with increasing
    /// limits if partition 0 overflows.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_partition_limit(mut self, limit: u8) -> Self {
        self.partition_limit = Some(limit.min(100));
        self
    }

    /// Set the cost model used during mode selection and trellis quantization.
    /// Expert-only — see [`LossyConfig::with_internal_params`].
    ///
    /// - [`CostModel::ZenwebpDefault`](super::api::CostModel::ZenwebpDefault)
    ///   (default): perceptual extensions enabled per method level —
    ///   PSY_WEIGHT_Y CSF table at m3+, SATD masking-alpha blend at m4+,
    ///   JND coefficient zeroing at m5+. Tuned for butteraugli/SSIMULACRA2.
    /// - [`CostModel::StrictLibwebpParity`](super::api::CostModel::StrictLibwebpParity):
    ///   disables those extensions so the encoder matches libwebp's algorithm
    ///   at the same `(quality, method, sns, filter, segments)`. Use this
    ///   when bit-comparing against libwebp output.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_cost_model(mut self, model: super::api::CostModel) -> Self {
        self.cost_model = model;
        self
    }

    /// Enable segment-map smoothing (3×3 majority filter on the per-MB
    /// segment map before per-segment quantizer setup). Expert-only —
    /// see [`LossyConfig::with_internal_params`].
    ///
    /// Equivalent to libwebp's `cwebp -pre 1` (`config->preprocessing & 1`,
    /// `analysis_enc.c:217-218`). Default off, matching libwebp.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_smooth_segment_map(mut self, on: bool) -> Self {
        self.smooth_segment_map = on;
        self
    }

    /// Enable a stat-collection encoder pass before the emit pass to refresh
    /// `level_costs` from the observed token distribution. Expert-only —
    /// see [`LossyConfig::with_internal_params`].
    ///
    /// Roughly **doubles** encode time at m4 in exchange for ~0.1% size
    /// win on photo content. libwebp does this by default; zenwebp keeps
    /// it OFF. Worth enabling inside `target_size` / `target_zensim` search
    /// loops where the marginal size win amortizes across multiple probes.
    /// Currently only m4 honors this — m5/m6 already saturate per-pass
    /// and multi-pass at those tiers regresses size.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_multi_pass_stats(mut self, on: bool) -> Self {
        self.multi_pass_stats = on;
        self
    }

    /// Apply a bundle of expert encoder knobs at once. Expert-only.
    ///
    /// `InternalParams` collects the advanced calibration axes that
    /// codec consumers don't usually need to set directly:
    /// `partition_limit`, `multi_pass_stats`, `smooth_segment_map`,
    /// `sharp_yuv`, `cost_model`. Each is `Option<_>` so caller only
    /// overrides the ones they care about; `None` keeps the existing
    /// (default) value.
    ///
    /// This is the recommended entry point when building grids for
    /// the picker training pipeline or for codec-calibration sweeps —
    /// pass an `InternalParams` produced by the picker runtime instead
    /// of chaining individual `with_*_expert` setters.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_internal_params(mut self, knobs: InternalParams) -> Self {
        if let Some(pl) = knobs.partition_limit {
            self = self.with_partition_limit(pl);
        }
        if let Some(b) = knobs.multi_pass_stats {
            self = self.with_multi_pass_stats(b);
        }
        if let Some(b) = knobs.smooth_segment_map {
            self = self.with_smooth_segment_map(b);
        }
        match knobs.sharp_yuv {
            Some(SharpYuvSetting::Off) => {
                self = self.with_sharp_yuv(false);
            }
            Some(SharpYuvSetting::On) => {
                self = self.with_sharp_yuv(true);
            }
            Some(SharpYuvSetting::Custom(cfg)) => {
                self = self.with_sharp_yuv_config(cfg);
            }
            None => {}
        }
        if let Some(cm) = knobs.cost_model {
            self = self.with_cost_model(cm);
        }
        self
    }

    /// Set resource limits for validation.
    #[must_use]
    pub fn with_limits(mut self, limits: Limits) -> Self {
        self.limits = limits;
        self
    }

    /// Set maximum dimensions.
    #[must_use]
    pub fn with_max_dimensions(mut self, width: u32, height: u32) -> Self {
        self.limits = self.limits.max_dimensions(width, height);
        self
    }

    /// Set maximum memory usage in bytes.
    #[must_use]
    pub fn with_max_memory(mut self, bytes: u64) -> Self {
        self.limits = self.limits.max_memory(bytes);
        self
    }

    /// Estimate peak memory usage for encoding an image.
    ///
    /// Returns the typical peak memory consumption in bytes.
    #[must_use]
    pub fn estimate_memory(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(
            width,
            height,
            bpp,
            &crate::EncoderConfig::Lossy(self.clone()),
        )
        .peak_memory_bytes
    }

    /// Estimate worst-case peak memory usage for encoding an image.
    ///
    /// Returns the maximum expected peak memory (high-entropy content).
    #[must_use]
    pub fn estimate_memory_ceiling(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(
            width,
            height,
            bpp,
            &crate::EncoderConfig::Lossy(self.clone()),
        )
        .peak_memory_bytes_max
    }

    /// Crate-internal: encode an interleaved RGB8 or RGBA8 buffer
    /// (`width * height * bpp` bytes, where `bpp` is 3 for RGB8 and
    /// 4 for RGBA8) and return both the WebP bytes and any
    /// [`ZensimEncodeMetrics`] from a `target_zensim` iteration. For
    /// non-target-zensim configs the metrics struct is filled with the
    /// [`ZensimEncodeMetrics::no_target`](super::zensim_target::ZensimEncodeMetrics::no_target)
    /// variant — `targets_met=true`, `passes_used=1`, score `NaN`.
    ///
    /// `layout` must be either [`PixelLayout::Rgb8`] or
    /// [`PixelLayout::Rgba8`]; the gate in
    /// [`EncodeRequest::try_encode_target_zensim_with_metrics`] guards
    /// other layouts before they reach this entry.
    ///
    /// This is the iteration loop's internal entry. Public callers
    /// reach the same machinery via
    /// [`EncodeRequest::encode_with_metrics`](super::api::EncodeRequest::encode_with_metrics)
    /// or [`EncodeRequest::encode`](super::api::EncodeRequest::encode).
    pub(crate) fn encode_pixels_with_metrics(
        &self,
        pixels: &[u8],
        layout: super::api::PixelLayout,
        width: u32,
        height: u32,
    ) -> Result<
        (
            alloc::vec::Vec<u8>,
            super::zensim_target::ZensimEncodeMetrics,
        ),
        super::api::EncodeError,
    > {
        encode_pixels_with_metrics_impl(self, pixels, layout, width, height)
    }
}

/// Configuration for lossless (VP8L) encoding.
///
/// Lossless encoding uses prediction, color transforms, and LZ77 compression
/// to achieve perfect reconstruction of the original pixels.
#[derive(Clone)]
#[non_exhaustive]
pub struct LosslessConfig {
    /// Encoding effort (0.0 = fastest, 100.0 = best compression). Default: 75.0.
    pub quality: f32,
    /// Quality/speed tradeoff (0 = fast, 6 = slower but better). Default: 4.
    pub method: u8,
    /// Alpha channel quality (0-100, 100 = lossless alpha). Default: 100.
    pub alpha_quality: u8,
    /// Target file size in bytes (0 = disabled). Default: 0.
    pub target_size: u32,
    /// Near-lossless preprocessing (0 = max preprocessing, 100 = off). Default: 100.
    pub near_lossless: u8,
    /// Preserve exact RGB values under transparent areas. Default: false.
    pub exact: bool,
    /// Resource limits for validation.
    pub limits: Limits,
}

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

impl LosslessConfig {
    /// Create a new lossless encoder configuration with defaults.
    ///
    /// Default: quality 75, method 4, fully lossless (near_lossless = 100).
    #[must_use]
    pub fn new() -> Self {
        Self {
            quality: 75.0,
            method: 4,
            alpha_quality: 100,
            target_size: 0,
            near_lossless: 100,
            exact: false,
            limits: Limits::none(),
        }
    }

    /// Set encoding effort (0.0 = fastest, 100.0 = best compression).
    #[must_use]
    pub fn with_quality(mut self, quality: f32) -> Self {
        self.quality = quality.clamp(0.0, 100.0);
        self
    }

    /// Set method (0 = fastest, 6 = slowest but best compression).
    #[must_use]
    pub fn with_method(mut self, method: u8) -> Self {
        self.method = method.min(6);
        self
    }

    /// Set alpha channel quality (0-100, 100 = lossless alpha).
    #[must_use]
    pub fn with_alpha_quality(mut self, quality: u8) -> Self {
        self.alpha_quality = quality.min(100);
        self
    }

    /// Target output file size in bytes (encoder will adjust effort).
    #[must_use]
    pub fn with_target_size(mut self, size: u32) -> Self {
        self.target_size = size;
        self
    }

    /// Enable near-lossless preprocessing (0 = max preprocessing, 100 = off).
    ///
    /// Values < 100 allow slight color changes to improve compression while
    /// maintaining the illusion of lossless quality.
    #[must_use]
    pub fn with_near_lossless(mut self, value: u8) -> Self {
        self.near_lossless = value.min(100);
        self
    }

    /// Preserve exact RGB values even under fully transparent pixels.
    ///
    /// By default, RGB values under alpha=0 may be modified for better compression.
    /// Enable this to preserve them exactly (e.g., for alpha compositing workflows).
    #[must_use]
    pub fn with_exact(mut self, exact: bool) -> Self {
        self.exact = exact;
        self
    }

    /// Set resource limits for validation.
    #[must_use]
    pub fn with_limits(mut self, limits: Limits) -> Self {
        self.limits = limits;
        self
    }

    /// Set maximum dimensions.
    #[must_use]
    pub fn with_max_dimensions(mut self, width: u32, height: u32) -> Self {
        self.limits = self.limits.max_dimensions(width, height);
        self
    }

    /// Set maximum memory usage in bytes.
    #[must_use]
    pub fn with_max_memory(mut self, bytes: u64) -> Self {
        self.limits = self.limits.max_memory(bytes);
        self
    }

    /// Estimate peak memory usage for encoding an image.
    ///
    /// Returns the typical peak memory consumption in bytes.
    #[must_use]
    pub fn estimate_memory(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(
            width,
            height,
            bpp,
            &crate::EncoderConfig::Lossless(self.clone()),
        )
        .peak_memory_bytes
    }

    /// Estimate worst-case peak memory usage for encoding an image.
    ///
    /// Returns the maximum expected peak memory (high-entropy content).
    #[must_use]
    pub fn estimate_memory_ceiling(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(
            width,
            height,
            bpp,
            &crate::EncoderConfig::Lossless(self.clone()),
        )
        .peak_memory_bytes_max
    }
}

/// Encoder configuration enum for runtime mode selection.
///
/// Use this when you need to choose between lossy and lossless at runtime.
/// For compile-time mode selection, use [`LossyConfig`] or [`LosslessConfig`] directly.
///
/// # Example
///
/// ```rust
/// use zenwebp::{EncoderConfig, LossyConfig, LosslessConfig};
///
/// fn choose_config(has_transparency: bool) -> EncoderConfig {
///     if has_transparency {
///         EncoderConfig::Lossless(LosslessConfig::new())
///     } else {
///         EncoderConfig::Lossy(LossyConfig::new().with_quality(80.0))
///     }
/// }
/// ```
#[derive(Clone)]
pub enum EncoderConfig {
    /// Lossy (VP8) encoding configuration.
    Lossy(LossyConfig),
    /// Lossless (VP8L) encoding configuration.
    Lossless(LosslessConfig),
}

impl EncoderConfig {
    /// Create a new lossy encoder configuration.
    ///
    /// Convenience wrapper for `EncoderConfig::Lossy(LossyConfig::new())`.
    #[must_use]
    pub fn new_lossy() -> Self {
        Self::Lossy(LossyConfig::new())
    }

    /// Create a new lossless encoder configuration.
    ///
    /// Convenience wrapper for `EncoderConfig::Lossless(LosslessConfig::new())`.
    #[must_use]
    pub fn new_lossless() -> Self {
        Self::Lossless(LosslessConfig::new())
    }

    /// Create from a preset with the given quality.
    #[must_use]
    pub fn with_preset(preset: Preset, quality: f32) -> Self {
        Self::Lossy(LossyConfig::with_preset(preset, quality))
    }

    /// Set encoding quality (0.0 = smallest, 100.0 = best).
    ///
    /// Works for both lossy and lossless configurations.
    #[must_use]
    pub fn with_quality(mut self, quality: f32) -> Self {
        match &mut self {
            Self::Lossy(cfg) => cfg.quality = quality,
            Self::Lossless(cfg) => cfg.quality = quality,
        }
        self
    }

    /// Set encoding method (0-6, higher = better compression but slower).
    ///
    /// Works for both lossy and lossless configurations.
    #[must_use]
    pub fn with_method(mut self, method: u8) -> Self {
        match &mut self {
            Self::Lossy(cfg) => cfg.method = method,
            Self::Lossless(cfg) => cfg.method = method,
        }
        self
    }

    /// Set SNS strength (lossy only, 0-100). No effect on lossless.
    #[must_use]
    pub fn with_sns_strength(mut self, strength: u8) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.sns_strength = Some(strength);
        }
        self
    }

    /// Set filter strength (lossy only, 0-100). No effect on lossless.
    #[must_use]
    pub fn with_filter_strength(mut self, strength: u8) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.filter_strength = Some(strength);
        }
        self
    }

    /// Set filter sharpness (lossy only, 0-7). No effect on lossless.
    #[must_use]
    pub fn with_filter_sharpness(mut self, sharpness: u8) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.filter_sharpness = Some(sharpness);
        }
        self
    }

    /// Set number of segments (lossy only, 1-4). No effect on lossless.
    #[must_use]
    pub fn with_segments(mut self, segments: u8) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.segments = Some(segments);
        }
        self
    }

    /// Set partition limit (lossy only, 0-100). No effect on lossless.
    /// Expert-only — see [`LossyConfig::with_partition_limit`] for
    /// details.
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_partition_limit(mut self, limit: u8) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.partition_limit = Some(limit.min(100));
        }
        self
    }

    /// Set near-lossless preprocessing (lossless only, 0-100). No effect on lossy.
    #[must_use]
    pub fn with_near_lossless(mut self, value: u8) -> Self {
        if let Self::Lossless(cfg) = &mut self {
            cfg.near_lossless = value;
        }
        self
    }

    /// Set target file size in bytes. 0 = disabled.
    ///
    /// Works for both lossy and lossless configurations.
    #[must_use]
    pub fn with_target_size(mut self, bytes: u32) -> Self {
        match &mut self {
            Self::Lossy(cfg) => cfg.target_size = bytes,
            Self::Lossless(cfg) => cfg.target_size = bytes,
        }
        self
    }

    /// Set target PSNR in dB (lossy only). 0.0 = disabled. No effect on lossless.
    #[must_use]
    pub fn with_target_psnr(mut self, psnr: f32) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.target_psnr = psnr;
        }
        self
    }

    /// Set resource limits for dimensions and memory validation.
    ///
    /// Works for both lossy and lossless configurations.
    #[must_use]
    pub fn limits(mut self, limits: crate::Limits) -> Self {
        match &mut self {
            Self::Lossy(cfg) => cfg.limits = limits,
            Self::Lossless(cfg) => cfg.limits = limits,
        }
        self
    }

    /// Enable/disable sharp YUV conversion (lossy only). No effect on
    /// lossless. Expert-only — see [`LossyConfig::with_internal_params`].
    #[cfg(feature = "__expert")]
    #[must_use]
    pub fn with_sharp_yuv(mut self, enable: bool) -> Self {
        if let Self::Lossy(cfg) = &mut self {
            cfg.sharp_yuv = if enable {
                Some(zenyuv::SharpYuvConfig::default())
            } else {
                None
            };
        }
        self
    }

    /// Switch between lossy and lossless encoding.
    ///
    /// When switching, preserves common settings (quality, method, limits, etc.).
    #[must_use]
    pub fn with_lossless(self, enable: bool) -> Self {
        match (&self, enable) {
            (Self::Lossy(_), true) => {
                // Switch to lossless
                let q = self.get_quality();
                let m = self.get_method();
                let l = self.get_limits().clone();
                Self::Lossless(LosslessConfig {
                    quality: q,
                    method: m,
                    limits: l,
                    ..LosslessConfig::new()
                })
            }
            (Self::Lossless(_), false) => {
                // Switch to lossy
                let q = self.get_quality();
                let m = self.get_method();
                let l = self.get_limits().clone();
                Self::Lossy(LossyConfig {
                    quality: q,
                    method: m,
                    limits: l,
                    ..LossyConfig::new()
                })
            }
            _ => self, // Already in requested mode
        }
    }

    /// Estimate resource consumption for encoding an image with this config.
    ///
    /// Returns memory, time, and output size estimates.
    #[must_use]
    pub fn estimate(&self, width: u32, height: u32, bpp: u8) -> crate::heuristics::EncodeEstimate {
        crate::heuristics::estimate_encode(width, height, bpp, self)
    }

    /// Estimate peak memory usage for encoding an image.
    ///
    /// Returns the typical peak memory consumption in bytes.
    #[must_use]
    pub fn estimate_memory(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(width, height, bpp, self).peak_memory_bytes
    }

    /// Estimate worst-case peak memory usage for encoding an image.
    ///
    /// Returns the maximum expected peak memory (high-entropy content).
    #[must_use]
    pub fn estimate_memory_ceiling(&self, width: u32, height: u32, bpp: u8) -> u64 {
        crate::heuristics::estimate_encode(width, height, bpp, self).peak_memory_bytes_max
    }

    /// Check if this is a lossless configuration.
    #[must_use]
    pub fn is_lossless(&self) -> bool {
        matches!(self, Self::Lossless(_))
    }

    /// Get the underlying lossy config, if this is a lossy configuration.
    #[must_use]
    pub fn as_lossy(&self) -> Option<&LossyConfig> {
        match self {
            Self::Lossy(cfg) => Some(cfg),
            Self::Lossless(_) => None,
        }
    }

    /// Get the underlying lossless config, if this is a lossless configuration.
    #[must_use]
    pub fn as_lossless(&self) -> Option<&LosslessConfig> {
        match self {
            Self::Lossy(_) => None,
            Self::Lossless(cfg) => Some(cfg),
        }
    }
}

// Manual Debug implementations (Stop trait doesn't implement Debug)
impl core::fmt::Debug for LossyConfig {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("LossyConfig")
            .field("quality", &self.quality)
            .field("method", &self.method)
            .field("alpha_quality", &self.alpha_quality)
            .field("target_size", &self.target_size)
            .field("target_psnr", &self.target_psnr)
            .field("preset", &self.preset)
            .field("sharp_yuv", &self.sharp_yuv)
            .field("sns_strength", &self.sns_strength)
            .field("filter_strength", &self.filter_strength)
            .field("filter_sharpness", &self.filter_sharpness)
            .field("segments", &self.segments)
            .field("partition_limit", &self.partition_limit)
            .field("limits", &self.limits)
            .finish()
    }
}

impl core::fmt::Debug for LosslessConfig {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("LosslessConfig")
            .field("quality", &self.quality)
            .field("method", &self.method)
            .field("alpha_quality", &self.alpha_quality)
            .field("target_size", &self.target_size)
            .field("near_lossless", &self.near_lossless)
            .field("exact", &self.exact)
            .field("limits", &self.limits)
            .finish()
    }
}

impl core::fmt::Debug for EncoderConfig {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::Lossy(cfg) => f.debug_tuple("Lossy").field(cfg).finish(),
            Self::Lossless(cfg) => f.debug_tuple("Lossless").field(cfg).finish(),
        }
    }
}

// Conversion to internal EncoderParams
use super::api::EncoderParams;
use super::fast_math;

impl LossyConfig {
    pub(crate) fn to_params(&self) -> EncoderParams {
        // Base tuning values from preset (matches libwebp config_enc.c)
        let (sns, filter, sharp, segs) = match self.preset {
            Some(Preset::Default) => (50, 60, 0, 4),
            Some(Preset::Photo) => (80, 30, 3, 4),
            Some(Preset::Drawing) => (25, 10, 6, 4),
            Some(Preset::Icon) => (0, 0, 0, 4),
            Some(Preset::Text) => (0, 0, 0, 2),
            Some(Preset::Picture) => (80, 35, 4, 4),
            Some(Preset::Auto) | None => (50, 60, 0, 4),
        };

        EncoderParams {
            use_predictor_transform: true,
            use_lossy: true,
            lossy_quality: fast_math::roundf(self.quality) as u8,
            method: self.method,
            sns_strength: self.sns_strength.unwrap_or(sns),
            filter_strength: self.filter_strength.unwrap_or(filter),
            filter_sharpness: self.filter_sharpness.unwrap_or(sharp),
            num_segments: self.segments.unwrap_or(segs),
            preset: self.preset.unwrap_or(Preset::Default),
            target_size: self.target_size,
            target_psnr: self.target_psnr,
            sharp_yuv: self.sharp_yuv,
            alpha_quality: self.alpha_quality,
            partition_limit: self.partition_limit,
            exact: false, // Not applicable to lossy (alpha plane is lossless separately)
            smooth_segment_map: self.smooth_segment_map,
            cost_model: self.cost_model,
            multi_pass_stats: self.multi_pass_stats,
            segment_quant_overrides: self.segment_quant_overrides,
        }
    }
}

impl LosslessConfig {
    pub(crate) fn to_params(&self) -> EncoderParams {
        EncoderParams {
            use_predictor_transform: true,
            use_lossy: false,
            lossy_quality: fast_math::roundf(self.quality) as u8,
            method: self.method,
            sns_strength: 0,
            filter_strength: 0,
            filter_sharpness: 0,
            num_segments: 1,
            preset: Preset::Default, // Preset not used for lossless encoding
            target_size: self.target_size,
            target_psnr: 0.0,
            sharp_yuv: None,
            alpha_quality: self.alpha_quality,
            partition_limit: None, // Not applicable to lossless
            exact: self.exact,
            smooth_segment_map: false, // Not applicable to lossless
            cost_model: super::api::CostModel::ZenwebpDefault,
            multi_pass_stats: false, // Not applicable to lossless
            segment_quant_overrides: None,
        }
    }
}

impl EncoderConfig {
    pub(crate) fn to_params(&self) -> EncoderParams {
        match self {
            Self::Lossy(cfg) => cfg.to_params(),
            Self::Lossless(cfg) => cfg.to_params(),
        }
    }

    /// Get the quality value from either variant.
    pub(crate) fn get_quality(&self) -> f32 {
        match self {
            Self::Lossy(cfg) => cfg.quality,
            Self::Lossless(cfg) => cfg.quality,
        }
    }

    /// Get the method value from either variant.
    pub(crate) fn get_method(&self) -> u8 {
        match self {
            Self::Lossy(cfg) => cfg.method,
            Self::Lossless(cfg) => cfg.method,
        }
    }

    /// Get the limits from either variant.
    pub(crate) fn get_limits(&self) -> &Limits {
        match self {
            Self::Lossy(cfg) => &cfg.limits,
            Self::Lossless(cfg) => &cfg.limits,
        }
    }
}

// ============================================================================
// target_zensim integration: convenience encoders on LossyConfig.
// ============================================================================

#[cfg(feature = "target-zensim")]
fn encode_pixels_with_metrics_impl(
    cfg: &LossyConfig,
    pixels: &[u8],
    layout: super::api::PixelLayout,
    width: u32,
    height: u32,
) -> Result<
    (
        alloc::vec::Vec<u8>,
        super::zensim_target::ZensimEncodeMetrics,
    ),
    super::api::EncodeError,
> {
    if let Some(t) = cfg.target_zensim {
        return super::zensim_target::iteration::run(cfg, t, pixels, layout, width, height);
    }
    // No target_zensim → straight single-pass encode.
    encode_single_pass(cfg, pixels, layout, width, height)
}

#[cfg(not(feature = "target-zensim"))]
fn encode_pixels_with_metrics_impl(
    cfg: &LossyConfig,
    pixels: &[u8],
    layout: super::api::PixelLayout,
    width: u32,
    height: u32,
) -> Result<
    (
        alloc::vec::Vec<u8>,
        super::zensim_target::ZensimEncodeMetrics,
    ),
    super::api::EncodeError,
> {
    // Feature disabled — if target_zensim was set, fall back to a single
    // encode at the calibrated starting q for the (Photo) bucket. Match
    // zenjpeg's behavior: don't error, just behave like a normal encode.
    if let Some(t) = cfg.target_zensim {
        let mut probe = cfg.clone();
        probe.target_zensim = None;
        probe.quality = super::zensim_target::zensim_to_starting_q_for_bucket(
            t.target,
            super::analysis::ImageContentType::Photo,
        )
        .clamp(0.0, 100.0);
        return encode_single_pass(&probe, pixels, layout, width, height);
    }
    encode_single_pass(cfg, pixels, layout, width, height)
}

/// Single-pass encode (Rgb8 or Rgba8), no metrics. Plumb through
/// `EncodeRequest` to reuse the existing entry point.
fn encode_single_pass(
    cfg: &LossyConfig,
    pixels: &[u8],
    layout: super::api::PixelLayout,
    width: u32,
    height: u32,
) -> Result<
    (
        alloc::vec::Vec<u8>,
        super::zensim_target::ZensimEncodeMetrics,
    ),
    super::api::EncodeError,
> {
    let req = super::api::EncodeRequest::lossy(cfg, pixels, layout, width, height);
    match req.encode() {
        Ok(bytes) => {
            let len = bytes.len();
            Ok((
                bytes,
                super::zensim_target::ZensimEncodeMetrics::no_target(len),
            ))
        }
        Err(at_err) => Err(at_err.decompose().0),
    }
}

// ============================================================================
// validate() — opt-in fail-fast checks. Encode paths still clamp.
// ============================================================================

#[cfg(feature = "target-zensim")]
use super::validation::TARGET_ZENSIM_RANGE;
use super::validation::{
    self as v, ALPHA_QUALITY_RANGE, FILTER_SHARPNESS_RANGE, FILTER_STRENGTH_RANGE, METHOD_RANGE,
    NEAR_LOSSLESS_RANGE, PARTITION_LIMIT_RANGE, SEGMENTS_RANGE, SNS_STRENGTH_RANGE,
    ValidationError,
};

impl LossyConfig {
    /// Check every documented range and cross-parameter invariant on
    /// this config without encoding anything.
    ///
    /// Returns `Ok(())` if every field is in range and the
    /// target-mode exclusivity rule (at most one of `target_size`,
    /// `target_psnr`, and — when the unstable `target-zensim` cargo
    /// feature is enabled — `target_zensim`) is honoured; otherwise
    /// returns the first offending [`ValidationError`].
    ///
    /// Existing encode entry points (`EncodeRequest::encode` etc.)
    /// continue to clamp out-of-range values for backwards
    /// compatibility — `validate()` is opt-in for callers that want a
    /// fail-fast signal. Typical usage:
    ///
    /// ```
    /// use zenwebp::LossyConfig;
    /// let cfg = LossyConfig::new().with_quality(80.0);
    /// cfg.validate().unwrap();
    /// ```
    ///
    /// Note that `with_*` setters already clamp, so a
    /// builder-constructed config will pass validation. The interesting
    /// failure cases are direct struct construction (allowed by the
    /// public fields) and the cross-parameter target-exclusivity
    /// invariant, which no setter can catch.
    pub fn validate(&self) -> Result<(), ValidationError> {
        v::check_quality(self.quality)?;
        v::check_method(self.method)?;
        v::check_alpha_quality(self.alpha_quality)?;
        v::check_target_psnr(self.target_psnr)?;

        if let Some(s) = self.sns_strength
            && !SNS_STRENGTH_RANGE.contains(&s)
        {
            return Err(ValidationError::SnsStrengthOutOfRange {
                value: s,
                valid: SNS_STRENGTH_RANGE,
            });
        }
        if let Some(s) = self.filter_strength
            && !FILTER_STRENGTH_RANGE.contains(&s)
        {
            return Err(ValidationError::FilterStrengthOutOfRange {
                value: s,
                valid: FILTER_STRENGTH_RANGE,
            });
        }
        if let Some(s) = self.filter_sharpness
            && !FILTER_SHARPNESS_RANGE.contains(&s)
        {
            return Err(ValidationError::FilterSharpnessOutOfRange {
                value: s,
                valid: FILTER_SHARPNESS_RANGE,
            });
        }
        if let Some(s) = self.segments
            && !SEGMENTS_RANGE.contains(&s)
        {
            return Err(ValidationError::SegmentsOutOfRange {
                value: s,
                valid: SEGMENTS_RANGE,
            });
        }
        if let Some(p) = self.partition_limit
            && !PARTITION_LIMIT_RANGE.contains(&p)
        {
            return Err(ValidationError::PartitionLimitOutOfRange {
                value: p,
                valid: PARTITION_LIMIT_RANGE,
            });
        }

        #[cfg(feature = "target-zensim")]
        if let Some(t) = self.target_zensim {
            if !t.target.is_finite() || !TARGET_ZENSIM_RANGE.contains(&t.target) {
                return Err(ValidationError::TargetZensimOutOfRange {
                    value: t.target,
                    valid: TARGET_ZENSIM_RANGE,
                });
            }
            if t.max_passes == 0 {
                return Err(ValidationError::TargetZensimMaxPassesZero {
                    value: t.max_passes,
                });
            }
            for (field, opt) in [
                ("max_overshoot", t.max_overshoot),
                ("max_undershoot", t.max_undershoot),
                ("max_undershoot_ship", t.max_undershoot_ship),
            ] {
                if let Some(val) = opt
                    && (!val.is_finite() || val < 0.0)
                {
                    return Err(ValidationError::TargetZensimToleranceInvalid {
                        field,
                        value: val,
                    });
                }
            }
        }

        if let Some(cfg) = self.sharp_yuv
            && (!cfg.convergence_threshold.is_finite() || cfg.convergence_threshold < 0.0)
        {
            return Err(ValidationError::SharpYuvConvergenceThresholdInvalid {
                value: cfg.convergence_threshold,
            });
        }

        // Cross-param: at most one target mode active. `target_size==0`
        // and `target_psnr==0.0` mean "disabled". When the
        // `target-zensim` cargo feature is enabled, `target_zensim`
        // joins the same exclusivity rule (`None` = disabled).
        let size_set = self.target_size != 0;
        let psnr_set = self.target_psnr != 0.0;
        if size_set && psnr_set {
            return Err(ValidationError::TargetMutuallyExclusive {
                first: "target_size",
                second: "target_psnr",
            });
        }
        #[cfg(feature = "target-zensim")]
        {
            let zensim_set = self.target_zensim.is_some();
            if size_set && zensim_set {
                return Err(ValidationError::TargetMutuallyExclusive {
                    first: "target_size",
                    second: "target_zensim",
                });
            }
            if psnr_set && zensim_set {
                return Err(ValidationError::TargetMutuallyExclusive {
                    first: "target_psnr",
                    second: "target_zensim",
                });
            }
        }

        Ok(())
    }
}

impl LosslessConfig {
    /// Check every documented range on this config without encoding.
    ///
    /// See [`LossyConfig::validate`] for the rationale and contract;
    /// the lossless variant has no cross-parameter invariants.
    pub fn validate(&self) -> Result<(), ValidationError> {
        v::check_quality(self.quality)?;
        v::check_method(self.method)?;
        v::check_alpha_quality(self.alpha_quality)?;
        if !NEAR_LOSSLESS_RANGE.contains(&self.near_lossless) {
            return Err(ValidationError::NearLosslessOutOfRange {
                value: self.near_lossless,
                valid: NEAR_LOSSLESS_RANGE,
            });
        }
        let _ = ALPHA_QUALITY_RANGE; // keep import live across cfg combinations
        let _ = METHOD_RANGE;
        Ok(())
    }
}

impl EncoderConfig {
    /// Validate the underlying [`LossyConfig`] or [`LosslessConfig`].
    ///
    /// See [`LossyConfig::validate`] / [`LosslessConfig::validate`] for
    /// the field-by-field contract.
    pub fn validate(&self) -> Result<(), ValidationError> {
        match self {
            Self::Lossy(c) => c.validate(),
            Self::Lossless(c) => c.validate(),
        }
    }
}

#[cfg(feature = "__expert")]
impl InternalParams {
    /// Validate every field that's `Some` on this expert-knob bundle.
    ///
    /// Returns `Ok(())` for an all-`None` bundle. For each set field,
    /// applies the same range check that
    /// [`LossyConfig::validate`] would after the bundle is folded in
    /// via [`LossyConfig::with_internal_params`]. The `cost_model` and
    /// `sharp_yuv` enum variants are well-formed by construction
    /// (Rust's type system guarantees it); only their numeric
    /// payloads (e.g., `SharpYuvConfig::convergence_threshold`) need
    /// runtime checks.
    pub fn validate(&self) -> Result<(), ValidationError> {
        if let Some(p) = self.partition_limit
            && !PARTITION_LIMIT_RANGE.contains(&p)
        {
            return Err(ValidationError::PartitionLimitOutOfRange {
                value: p,
                valid: PARTITION_LIMIT_RANGE,
            });
        }
        if let Some(SharpYuvSetting::Custom(cfg)) = &self.sharp_yuv
            && (!cfg.convergence_threshold.is_finite() || cfg.convergence_threshold < 0.0)
        {
            return Err(ValidationError::SharpYuvConvergenceThresholdInvalid {
                value: cfg.convergence_threshold,
            });
        }
        Ok(())
    }
}