Skip to main content

phasm_core/stego/armor/
pipeline.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Armor encode/decode pipeline.
6//!
7//! Armor embeds messages using STDM (Spread Transform Dither Modulation)
8//! into recompression-stable DCT coefficients, protected by Reed-Solomon
9//! error correction.
10//!
11//! **Robustness features:**
12//! - Frequency-restricted embedding (zigzag 1..=15) for stability
13//! - Pre-clamp for pixel-domain settling
14//! - 1-byte mean-QT header with 7x majority voting (56 units)
15//! - Sequential repetition copies for soft majority voting
16//! - +/-30% decode-side delta sweep (~21 candidates)
17//! - Brute-force (r, parity) search on decode -- no fragile r-header
18//! - DFT ring payload: resize-robust second layer in frequency domain
19
20use crate::codec::jpeg::JpegImage;
21use crate::codec::jpeg::dct::DctGrid;
22use crate::codec::jpeg::pixels;
23use crate::stego::armor::ecc;
24use crate::stego::armor::embedding::{self, stdm_embed, stdm_extract_soft};
25use crate::stego::armor::fft2d;
26use crate::stego::armor::fortress;
27use crate::stego::armor::repetition;
28use crate::stego::armor::resample;
29use crate::stego::armor::selection::compute_stability_map;
30use crate::stego::armor::spreading::{generate_spreading_vectors, SPREAD_LEN};
31use crate::stego::armor::template;
32use crate::stego::crypto;
33use crate::stego::error::StegoError;
34use crate::stego::frame;
35use crate::stego::payload::{self, PayloadData};
36use crate::stego::permute;
37use crate::stego::progress;
38
39#[cfg(feature = "parallel")]
40use rayon::prelude::*;
41
42use crate::stego::quality::{self, EncodeQuality, ArmorMetrics};
43
44/// Number of embedding units for the header.
45/// 1 byte x 8 bits x 7 copies = 56 units.
46const HEADER_UNITS: usize = embedding::HEADER_UNITS; // 56
47const HEADER_COPIES: usize = embedding::HEADER_COPIES; // 7
48
49/// Total progress steps for Armor encode via STDM path.
50/// 1 (DFT template) + 1 (pre-clamp) + 1 (stability map) + 1 (RS+spreading)
51/// + 1 (STDM embed) + 1 (JPEG write) = 6.
52pub const ARMOR_ENCODE_STEPS: u32 = 6;
53
54/// Total progress steps for Armor encode via Fortress path.
55/// 1 (pre-settle) + 1 (fortress encode) + 1 (JPEG write) = 3.
56const ARMOR_ENCODE_FORTRESS_STEPS: u32 = 3;
57
58/// Encode a text message into a cover JPEG using Armor mode.
59///
60/// # Arguments
61/// - `image_bytes`: Raw bytes of the cover JPEG image.
62/// - `message`: The plaintext message to embed (must fit within capacity).
63/// - `passphrase`: Used for both structural key derivation and encryption.
64///
65/// # Returns
66/// The stego JPEG image as bytes, or an error if the image is too small
67/// or the message exceeds the embedding capacity.
68///
69/// # Errors
70/// - [`StegoError::InvalidJpeg`] if `image_bytes` is not a valid baseline JPEG.
71/// - [`StegoError::NoLuminanceChannel`] if the image has no Y component.
72/// - [`StegoError::ImageTooSmall`] if there are too few stable positions.
73/// - [`StegoError::MessageTooLarge`] if the RS-encoded frame exceeds capacity.
74pub fn armor_encode(
75    image_bytes: &[u8],
76    message: &str,
77    passphrase: &str,
78) -> Result<Vec<u8>, StegoError> {
79    armor_encode_impl(image_bytes, message, passphrase)
80        .map(|(bytes, _)| bytes)
81}
82
83/// Encode with Armor mode and return the encode quality score.
84pub fn armor_encode_with_quality(
85    image_bytes: &[u8],
86    message: &str,
87    passphrase: &str,
88) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
89    armor_encode_impl(image_bytes, message, passphrase)
90}
91
92fn armor_encode_impl(
93    image_bytes: &[u8],
94    message: &str,
95    passphrase: &str,
96) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
97    // Initialize encode progress (STDM path assumed; adjusted if Fortress).
98    progress::init(ARMOR_ENCODE_STEPS);
99
100    // Build the payload (text + compression, no files for Armor).
101    let payload_bytes = payload::encode_payload(message, &[])?;
102
103    let mut img = JpegImage::from_bytes(image_bytes)?;
104
105    // Validate dimensions before any heavy processing.
106    let fi = img.frame_info();
107    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;
108
109    if img.num_components() == 0 {
110        return Err(StegoError::NoLuminanceChannel);
111    }
112
113    // Try Fortress (BA-QIM on DC) first if the message fits.
114    // For empty passphrase, use compact frame (saves 28 bytes of overhead).
115    let use_compact = passphrase.is_empty();
116    if let Ok(max_fort) = fortress::fortress_max_frame_bytes_ext(&img, use_compact) {
117        let fortress_frame = if use_compact {
118            let ct = crypto::encrypt_with(
119                &payload_bytes,
120                passphrase,
121                &crypto::FORTRESS_EMPTY_SALT,
122                &crypto::FORTRESS_EMPTY_NONCE,
123            )?;
124            frame::build_fortress_compact_frame(payload_bytes.len(), &ct)
125        } else {
126            let (ct, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;
127            frame::build_frame(payload_bytes.len(), &salt, &nonce, &ct)
128        };
129
130        if fortress_frame.len() <= max_fort {
131            // Switch to Fortress step count (shorter path).
132            progress::set_total(ARMOR_ENCODE_FORTRESS_STEPS);
133
134            // Pre-settle Y channel to QF75 QT tables before Fortress embedding.
135            pre_settle_for_fortress(&mut img)?;
136            progress::advance(); // Step 1: pre-settle
137
138            let fort_result = fortress::fortress_encode(&mut img, &fortress_frame, passphrase)?;
139            progress::advance(); // Step 2: fortress encode
140
141            let stego_bytes = if let Ok(bytes) = img.to_bytes() { bytes } else {
142                img.rebuild_huffman_tables();
143                img.to_bytes().map_err(StegoError::InvalidJpeg)?
144            };
145            progress::advance(); // Step 3: JPEG write
146
147            // Fortress quality: use actual r and parity from encode, pre-settled to QF75.
148            let fill_ratio = fortress_frame.len() as f64 / max_fort as f64;
149            // After pre-settle, compute mean_qt from the settled QT.
150            let fort_qt_id = img.frame_info().components[0].quant_table_id as usize;
151            let fort_mean_qt = img.quant_table(fort_qt_id)
152                .map_or(10.0, |qt| embedding::compute_mean_qt(&qt.values));
153            let encode_quality = quality::armor_robustness_score(&ArmorMetrics {
154                repetition_factor: fort_result.repetition_factor,
155                parity_symbols: fort_result.parity_symbols,
156                fortress: true,
157                mean_qt: fort_mean_qt,
158                fill_ratio,
159                delta: 12.0,
160            });
161            return Ok((stego_bytes, encode_quality));
162        }
163    }
164
165    // Full frame for STDM path (always uses random salt + nonce).
166    let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;
167    let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
168
169    // Phase 3: Embed DFT template + ring payload BEFORE STDM.
170    embed_dft_template(&mut img, passphrase, message)?;
171    progress::advance(); // Step 1: DFT template
172
173    // Pre-clamp pass: IDCT -> clamp [0,255] -> DCT on all Y-channel blocks.
174    pre_clamp_y_channel(&mut img)?;
175    progress::advance(); // Step 2: pre-clamp
176
177    // 1. Compute stability map for Y channel (frequency-restricted).
178    let qt_id = img.frame_info().components[0].quant_table_id as usize;
179    let qt = img
180        .quant_table(qt_id)
181        .ok_or(StegoError::NoLuminanceChannel)?;
182    let cost_map = compute_stability_map(img.dct_grid(0), qt);
183    progress::advance(); // Step 3: stability map
184
185    // 2. Derive structural key with Armor salt.
186    let structural_key = crypto::derive_armor_structural_key(passphrase)?;
187    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
188    let spread_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
189
190    // 3. Select and permute stable positions.
191    let positions = permute::select_and_permute(&cost_map, &perm_seed);
192    let num_units = positions.len() / SPREAD_LEN;
193    if num_units == 0 {
194        return Err(StegoError::ImageTooSmall);
195    }
196    let n_used = num_units * SPREAD_LEN;
197    let positions = &positions[..n_used];
198
199    // 4-5. frame_bytes already built above (shared with Fortress path).
200
201    // 6. Compute mean QT from actual quantization table.
202    let mean_qt = embedding::compute_mean_qt(&qt.values);
203    let header_byte = embedding::encode_mean_qt(mean_qt);
204    let bootstrap_delta = embedding::BOOTSTRAP_DELTA;
205    let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
206
207    // 7. Build header: 7 copies of 1 header byte (1 x 8 bits x 7 copies = 56 units)
208    let mut all_bits = Vec::with_capacity(num_units);
209    for _ in 0..HEADER_COPIES {
210        for bp in (0..8).rev() {
211            all_bits.push((header_byte >> bp) & 1);
212        }
213    }
214
215    // 8. Decide Phase 1 vs Phase 2 encoding.
216    let payload_units = if num_units > HEADER_UNITS {
217        num_units - HEADER_UNITS
218    } else {
219        return Err(StegoError::ImageTooSmall);
220    };
221
222    // Find best Phase 2 parity tier (r>=3) in a single pass, caching the RS result.
223    let phase2_result: Option<(usize, Vec<u8>)> = {
224        let mut found = None;
225        for &parity in &ecc::PARITY_TIERS {
226            let rs_encoded = ecc::rs_encode_blocks_with_parity(&frame_bytes, parity);
227            let rs_bits_len = rs_encoded.len() * 8;
228            if rs_bits_len <= payload_units {
229                let r = repetition::compute_r(rs_bits_len, payload_units);
230                if r >= 3 {
231                    found = Some((parity, rs_encoded));
232                    break;
233                }
234            }
235        }
236        found
237    };
238
239    // Track metrics for quality score.
240    let (armor_r, armor_parity, armor_delta);
241
242    let embed_delta_fn: Box<dyn Fn(usize) -> f64> = if let Some((chosen_parity, rs_encoded)) = phase2_result {
243        // --- Phase 2 encode: use cached RS result ---
244        let rs_bits = frame::bytes_to_bits(&rs_encoded);
245
246        let r = repetition::compute_r(rs_bits.len(), payload_units);
247        let rs_bit_count_aligned = payload_units / r;
248        let mut rs_bits_padded = rs_bits;
249        rs_bits_padded.resize(rs_bit_count_aligned, 0);
250        let (rep_bits, _) = repetition::repetition_encode(&rs_bits_padded, payload_units);
251
252        let adaptive_delta = embedding::compute_delta_from_mean_qt(mean_qt, r);
253
254        armor_r = r;
255        armor_parity = chosen_parity;
256        armor_delta = adaptive_delta;
257
258        all_bits.extend_from_slice(&rep_bits[..payload_units.min(rep_bits.len())]);
259
260        Box::new(move |bit_idx| {
261            if bit_idx < HEADER_UNITS { bootstrap_delta } else { adaptive_delta }
262        })
263    } else {
264        // --- Phase 1 encode: fixed RS parity=64, no repetition ---
265        let rs_encoded = ecc::rs_encode_blocks(&frame_bytes);
266        let rs_bits = frame::bytes_to_bits(&rs_encoded);
267
268        if rs_bits.len() > payload_units {
269            return Err(StegoError::MessageTooLarge);
270        }
271
272        armor_r = 1;
273        armor_parity = 64;
274        armor_delta = reference_delta;
275
276        let mut payload_bits = rs_bits;
277        payload_bits.resize(payload_units, 0);
278        all_bits.extend_from_slice(&payload_bits);
279
280        Box::new(move |bit_idx| {
281            if bit_idx < HEADER_UNITS { bootstrap_delta } else { reference_delta }
282        })
283    };
284
285    let embed_count = all_bits.len().min(num_units);
286
287    // 9. Generate spreading vectors.
288    let vectors = generate_spreading_vectors(&spread_seed, embed_count);
289    progress::advance(); // Step 4: RS encode + spreading vectors
290
291    // 10. STDM embed each bit into coefficient groups.
292    let grid_mut = img.dct_grid_mut(0);
293    for bit_idx in 0..embed_count {
294        let group_start = bit_idx * SPREAD_LEN;
295        let group = &positions[group_start..group_start + SPREAD_LEN];
296
297        let mut coeffs = [0.0f64; SPREAD_LEN];
298        for (k, pos) in group.iter().enumerate() {
299            coeffs[k] = flat_get(grid_mut, pos.flat_idx as usize) as f64;
300        }
301
302        let delta = embed_delta_fn(bit_idx);
303        stdm_embed(&mut coeffs, &vectors[bit_idx], all_bits[bit_idx], delta);
304
305        for (k, pos) in group.iter().enumerate() {
306            let new_val = coeffs[k].round() as i16;
307            flat_set(grid_mut, pos.flat_idx as usize, new_val);
308        }
309    }
310
311    progress::advance(); // Step 5: STDM embed
312
313    // 11. Write modified JPEG.
314    let stego_bytes = if let Ok(bytes) = img.to_bytes() { bytes } else {
315        img.rebuild_huffman_tables();
316        img.to_bytes().map_err(StegoError::InvalidJpeg)?
317    };
318
319    progress::advance(); // Step 6: JPEG write
320
321    // Compute quality score for STDM path.
322    let fill_ratio = frame_bytes.len() as f64 / (payload_units / 8).max(1) as f64;
323    let encode_quality = quality::armor_robustness_score(&ArmorMetrics {
324        repetition_factor: armor_r,
325        parity_symbols: armor_parity,
326        fortress: false,
327        mean_qt,
328        fill_ratio,
329        delta: armor_delta,
330    });
331
332    Ok((stego_bytes, encode_quality))
333}
334
335/// Quality information from a successful decode.
336#[derive(Debug, Clone)]
337pub struct DecodeQuality {
338    /// Mode that was used: `frame::MODE_GHOST` or `frame::MODE_ARMOR`.
339    pub mode: u8,
340    /// Number of RS symbol errors corrected (0 for Ghost).
341    pub rs_errors_corrected: u32,
342    /// Maximum correctable RS errors across all blocks (0 for Ghost).
343    pub rs_error_capacity: u32,
344    /// Integrity percentage: 100 = pristine, 0 = barely recovered.
345    pub integrity_percent: u8,
346    /// Repetition factor used during decode (1 = Phase 1 / no repetition).
347    pub repetition_factor: u8,
348    /// RS parity symbols used per block.
349    pub parity_len: u16,
350    /// True if geometric recovery (Phase 3) was used to decode.
351    pub geometry_corrected: bool,
352    /// Number of template peaks detected (out of 32).
353    pub template_peaks_detected: u8,
354    /// Estimated rotation angle in degrees (0 if no geometry correction).
355    pub estimated_rotation_deg: f32,
356    /// Estimated scale factor (1.0 if no geometry correction).
357    pub estimated_scale: f32,
358    /// True if DFT ring was used (message may be truncated).
359    pub dft_ring_used: bool,
360    /// DFT ring capacity in bytes (0 if not applicable).
361    pub dft_ring_capacity: u16,
362    /// True if Fortress sub-mode (BA-QIM) was used for encoding.
363    pub fortress_used: bool,
364    /// Signal strength from LLR analysis (0.0 = no signal, higher = stronger).
365    /// Used to compute meaningful integrity when RS errors are 0.
366    pub signal_strength: f64,
367}
368
369impl DecodeQuality {
370    /// Create quality info for a Ghost decode (binary: always 100% if successful).
371    pub fn ghost() -> Self {
372        Self {
373            mode: crate::stego::frame::MODE_GHOST,
374            rs_errors_corrected: 0,
375            rs_error_capacity: 0,
376            integrity_percent: 100,
377            repetition_factor: 0,
378            parity_len: 0,
379            geometry_corrected: false,
380            template_peaks_detected: 0,
381            estimated_rotation_deg: 0.0,
382            estimated_scale: 1.0,
383            dft_ring_used: false,
384            dft_ring_capacity: 0,
385            fortress_used: false,
386            signal_strength: 0.0,
387        }
388    }
389
390    /// Create quality info from Armor RS decode stats with LLR signal quality.
391    ///
392    /// Combines LLR-based signal strength (70% weight) with RS error margin
393    /// (30% weight) to produce a meaningful integrity percentage even when
394    /// RS errors are 0 (because repetition coding absorbed all damage).
395    ///
396    /// - `signal_strength`: average |LLR| per copy per bit from extraction.
397    /// - `reference_llr`: expected |LLR| for a pristine embedding (delta/2 for
398    ///   STDM, step/2 for QIM).
399    pub fn from_rs_stats_with_signal(
400        stats: &ecc::RsDecodeStats,
401        repetition_factor: u8,
402        parity_len: u16,
403        signal_strength: f64,
404        reference_llr: f64,
405    ) -> Self {
406        let integrity = compute_integrity(signal_strength, stats, reference_llr);
407        Self {
408            mode: crate::stego::frame::MODE_ARMOR,
409            rs_errors_corrected: stats.total_errors as u32,
410            rs_error_capacity: stats.error_capacity as u32,
411            integrity_percent: integrity,
412            repetition_factor,
413            parity_len,
414            geometry_corrected: false,
415            template_peaks_detected: 0,
416            estimated_rotation_deg: 0.0,
417            estimated_scale: 1.0,
418            dft_ring_used: false,
419            dft_ring_capacity: 0,
420            fortress_used: false,
421            signal_strength,
422        }
423    }
424}
425
426/// Compute integrity from both LLR signal strength and RS error stats.
427///
428/// - `signal_strength`: average |LLR| per copy per bit from extraction.
429/// - `rs_stats`: Reed-Solomon error correction statistics.
430/// - `reference_llr`: expected |LLR| for a pristine embedding.
431///
432/// Weighting: 70% signal quality (LLR), 30% RS error margin.
433///
434/// For pristine images: signal_strength ≈ reference → integrity ~95-100%.
435/// For recompressed images: signal_strength drops → integrity ~60-80%.
436/// For severely degraded images: signal_strength near 0 → integrity ~30-50%.
437fn compute_integrity(signal_strength: f64, rs_stats: &ecc::RsDecodeStats, reference_llr: f64) -> u8 {
438    let llr_score = if reference_llr > 0.0 {
439        (signal_strength / reference_llr).clamp(0.0, 1.0)
440    } else {
441        1.0 // No reference available, assume good
442    };
443    let rs_score = if rs_stats.error_capacity == 0 {
444        1.0
445    } else {
446        let ratio = rs_stats.total_errors as f64 / rs_stats.error_capacity as f64;
447        (1.0 - ratio).max(0.0)
448    };
449    // Weight: 70% signal quality, 30% RS margin
450    let combined = 0.7 * llr_score + 0.3 * rs_score;
451    (combined * 100.0).round().clamp(0.0, 100.0) as u8
452}
453
454/// Compute average |LLR| from a slice of raw LLR values (Phase 1 path).
455fn compute_avg_abs_llr(llrs: &[f64]) -> f64 {
456    if llrs.is_empty() {
457        return 0.0;
458    }
459    let sum: f64 = llrs.iter().map(|llr| llr.abs()).sum();
460    sum / llrs.len() as f64
461}
462
463/// Decode a text message from a stego JPEG using Armor mode.
464///
465/// Tries standard decode with delta sweep first, then falls back to
466/// geometric recovery (Phase 3) for rotated/scaled images.
467///
468/// # Arguments
469/// - `stego_bytes`: Raw bytes of the stego JPEG image.
470/// - `passphrase`: The passphrase used during encoding.
471///
472/// # Returns
473/// A tuple of (decoded plaintext message, decode quality info).
474pub fn armor_decode(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
475    // Try Fortress first (fast magic-byte check on DC coefficients).
476    let img = JpegImage::from_bytes(stego_bytes)?;
477    if img.num_components() > 0
478        && let Ok(result) = fortress::fortress_decode(&img, passphrase) {
479            return Ok(result);
480        }
481
482    // Try standard STDM decode with delta sweep (reuse already-parsed image).
483    // try_armor_decode sets progress total and tracks fortress + phase 1/2 steps.
484    match try_armor_decode(&img, passphrase) {
485        Ok(result) => Ok(result),
486        Err(new_err) => {
487            // Try geometric recovery (Phase 3).
488            progress::advance(); // phase 3
489            match try_geometric_recovery(stego_bytes, passphrase) {
490                Ok(result) => Ok(result),
491                Err(_) => Err(new_err),
492            }
493        }
494    }
495}
496
497/// Armor decode with delta sweep: parse image once, try multiple mean_qt candidates.
498pub(crate) fn try_armor_decode(img: &JpegImage, passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
499    if img.num_components() == 0 {
500        return Err(StegoError::NoLuminanceChannel);
501    }
502
503    // 1. Compute frequency-restricted stability map.
504    let qt_id = img.frame_info().components[0].quant_table_id as usize;
505    let qt = img
506        .quant_table(qt_id)
507        .ok_or(StegoError::NoLuminanceChannel)?;
508    let cost_map = compute_stability_map(img.dct_grid(0), qt);
509
510    // 2. Derive structural key.
511    let structural_key = crypto::derive_armor_structural_key(passphrase)?;
512    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
513    let spread_seed: [u8; 32] = structural_key[32..].try_into().unwrap();
514
515    // 3. Select and permute stable positions.
516    let positions = permute::select_and_permute(&cost_map, &perm_seed);
517    let num_units = positions.len() / SPREAD_LEN;
518    if num_units == 0 {
519        return Err(StegoError::ImageTooSmall);
520    }
521    let n_used = num_units * SPREAD_LEN;
522    let positions = &positions[..n_used];
523
524    // 4. Generate spreading vectors for all units.
525    let vectors = generate_spreading_vectors(&spread_seed, num_units);
526
527    let grid = img.dct_grid(0);
528
529    // 5. Extract 1-byte header at bootstrap delta.
530    if num_units <= HEADER_UNITS {
531        return Err(StegoError::ImageTooSmall);
532    }
533    let header_byte = extract_header_byte(grid, positions, &vectors, embedding::BOOTSTRAP_DELTA, 0);
534    let header_mean_qt = embedding::decode_mean_qt(header_byte);
535
536    // 6. Compute current image's mean QT for comparison.
537    let current_mean_qt = embedding::compute_mean_qt(&qt.values);
538
539    let payload_units = num_units - HEADER_UNITS;
540
541    // 7. Build candidate mean_qt values for delta sweep.
542    // Wider sweep +/-30% in 3% steps (~21 candidates) from both header and current.
543    let mut raw_candidates = Vec::with_capacity(24);
544    raw_candidates.push(header_mean_qt);
545    raw_candidates.push(current_mean_qt);
546    for step in 1..=10 {
547        let factor = step as f64 * 0.03;
548        raw_candidates.push(header_mean_qt * (1.0 - factor));
549        raw_candidates.push(header_mean_qt * (1.0 + factor));
550    }
551
552    // Deduplicate (within 0.1 tolerance)
553    let mut candidates: Vec<f64> = Vec::with_capacity(raw_candidates.len());
554    for &c in &raw_candidates {
555        if c > 0.1 && !candidates.iter().any(|&existing| (existing - c).abs() < 0.1) {
556            candidates.push(c);
557        }
558    }
559
560    // Set progress total now that we know the candidate count.
561    // fortress(1) + phase1(nc) + phase2(nc) + phase3(1) per run.
562    // Doubled fortress+phase1+phase2 for potential second run via geometric recovery.
563    // Ghost decode resets progress separately (its own GHOST_DECODE_STEPS total).
564    let nc = candidates.len() as u32;
565    // Only set total on first call; geometric recovery calls us again.
566    if progress::get().1 == 0 {
567        let total = (2 * (1 + nc + nc) + 1).max(50);
568        // Use set_total (not init) to avoid resetting STEP — in parallel mode,
569        // other threads may have already advanced the counter.
570        progress::set_total(total);
571    }
572    progress::advance(); // fortress check already done by caller
573
574    // 8. Pass 1: Try Phase 1 for ALL candidates first (fast per candidate).
575    #[cfg(feature = "parallel")]
576    {
577        let result = candidates.par_iter().find_map_first(|&mean_qt| {
578            if progress::is_cancelled() { return Some(Err(StegoError::Cancelled)); }
579            let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
580            match decode_phase1_with_offset(
581                grid, positions, &vectors, reference_delta, num_units, HEADER_UNITS,
582                passphrase,
583            ) {
584                Ok(result) => Some(Ok(result)),
585                Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
586                Err(_) => { progress::advance(); None }
587            }
588        });
589        match result {
590            Some(Ok(payload)) => return Ok(payload),
591            Some(Err(e)) => return Err(e),
592            None => {} // fall through to Phase 2
593        }
594    }
595    #[cfg(not(feature = "parallel"))]
596    for &mean_qt in &candidates {
597        progress::check_cancelled()?;
598        let reference_delta = embedding::compute_delta_from_mean_qt(mean_qt, 1);
599
600        if let Ok(result) = decode_phase1_with_offset(
601            grid, positions, &vectors, reference_delta, num_units, HEADER_UNITS,
602            passphrase,
603        ) {
604            return Ok(result);
605        }
606        progress::advance();
607    }
608
609    // 9. Pass 2: Try Phase 2 for ALL candidates (expensive, only if all Phase 1 failed).
610    // Pre-build all (parity, r, delta) candidates across ALL mean_qt values,
611    // pre-extract LLRs for all unique deltas, then search in one parallel sweep.
612    let mut all_p2_candidates: Vec<(usize, usize, f64)> = Vec::new();
613    for &mean_qt in &candidates {
614        for &parity in &ecc::PARITY_TIERS {
615            let candidate_rs = compute_candidate_rs(payload_units, parity);
616            for r in candidate_rs {
617                let delta = embedding::compute_delta_from_mean_qt(mean_qt, r);
618                all_p2_candidates.push((parity, r, delta));
619            }
620        }
621    }
622
623    // Pre-extract LLRs for all unique deltas (sequential; extraction itself is parallel internally).
624    let mut cached_llrs: Vec<(f64, Vec<f64>)> = Vec::new();
625    for &(_, _, delta) in &all_p2_candidates {
626        get_or_extract_llrs(
627            &mut cached_llrs, delta,
628            grid, positions, &vectors, num_units, HEADER_UNITS,
629        );
630    }
631    progress::check_cancelled()?;
632
633    // Read-only snapshot for parallel access.
634    let llr_cache: &[(f64, Vec<f64>)] = &cached_llrs;
635
636    let find_llrs = |delta: f64| -> &[f64] {
637        for (cached_delta, llrs) in llr_cache.iter() {
638            if (cached_delta - delta).abs() < 0.001 {
639                return llrs;
640            }
641        }
642        &[]
643    };
644
645    let try_p2_candidate = |&(parity, r, adaptive_delta): &(usize, usize, f64)| -> Option<Result<(PayloadData, DecodeQuality), StegoError>> {
646        if progress::is_cancelled() { return Some(Err(StegoError::Cancelled)); }
647        let raw_llrs = find_llrs(adaptive_delta);
648
649        let rs_bit_count = payload_units / r;
650        if rs_bit_count == 0 { return None; }
651        let used_llrs = rs_bit_count * r;
652        if used_llrs > raw_llrs.len() { return None; }
653
654        let (voted_bits, rep_quality) = repetition::repetition_decode_soft_with_quality(
655            &raw_llrs[..used_llrs], rs_bit_count,
656        );
657        let voted_bytes = frame::bits_to_bytes(&voted_bits);
658
659        let (decoded_frame, rs_stats) = try_rs_decode_frame_with_parity(&voted_bytes, parity)?;
660        let parsed = frame::parse_frame(&decoded_frame).ok()?;
661        match crypto::decrypt(&parsed.ciphertext, passphrase, &parsed.salt, &parsed.nonce) {
662            Ok(plaintext) => {
663                let len = parsed.plaintext_len as usize;
664                if len > plaintext.len() { return None; }
665                let payload_data = payload::decode_payload(&plaintext[..len]).ok()?;
666                let reference_llr = adaptive_delta / 2.0;
667                let quality = DecodeQuality::from_rs_stats_with_signal(
668                    &rs_stats, r as u8, parity as u16,
669                    rep_quality.avg_abs_llr_per_copy, reference_llr,
670                );
671                Some(Ok((payload_data, quality)))
672            }
673            Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
674            Err(_) => None,
675        }
676    };
677
678    #[cfg(feature = "parallel")]
679    let p2_result = all_p2_candidates.par_iter().find_map_first(try_p2_candidate);
680    #[cfg(not(feature = "parallel"))]
681    let p2_result = all_p2_candidates.iter().find_map(try_p2_candidate);
682
683    // Advance progress for Phase 2 (bulk advance since search is now flat).
684    for _ in 0..candidates.len() { progress::advance(); }
685
686    match p2_result {
687        Some(Ok(payload)) => return Ok(payload),
688        Some(Err(e)) => return Err(e),
689        None => {}
690    }
691
692    Err(StegoError::FrameCorrupted)
693}
694
695/// Armor decode without Fortress: STDM delta sweep + Phase 3 geometric recovery.
696///
697/// Used by the parallel smart_decode path to run STDM and Phase 3 concurrently
698/// with Fortress (which runs on a separate thread).
699pub(crate) fn armor_decode_no_fortress(img: &JpegImage, stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
700    match try_armor_decode(img, passphrase) {
701        Ok(result) => Ok(result),
702        Err(_stdm_err) => {
703            progress::check_cancelled()?;
704            match try_geometric_recovery(stego_bytes, passphrase) {
705                Ok(result) => Ok(result),
706                Err(_) => Err(_stdm_err),
707            }
708        }
709    }
710}
711
712/// Phase 3 geometric recovery: detect DFT template, estimate transform, resample, retry.
713/// Also tries DFT ring payload extraction as fallback.
714pub(crate) fn try_geometric_recovery(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
715    use crate::stego::armor::dft_payload;
716
717    let img = JpegImage::from_bytes(stego_bytes)?;
718
719    if img.num_components() == 0 {
720        return Err(StegoError::NoLuminanceChannel);
721    }
722
723    // Convert to pixel domain and compute 2D FFT.
724    let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(&img)
725        .ok_or(StegoError::NoLuminanceChannel)?;
726    let spectrum = fft2d::fft2d(&luma_pixels, w, h);
727
728    // Generate expected template peaks and search for them.
729    let peaks = template::generate_template_peaks(passphrase, w, h)?;
730    let detected = template::detect_template(&spectrum, &peaks);
731
732    // Estimate the geometric transform from detected peaks.
733    let transform = template::estimate_transform(&detected)
734        .ok_or(StegoError::FrameCorrupted)?;
735
736    // Skip if transform is essentially identity (fast path would have worked).
737    if transform.rotation_rad.abs() < 0.001 && (transform.scale - 1.0).abs() < 0.001 {
738        // Try DFT ring extraction directly (no geometry correction needed)
739        if let Some(ring_bytes) = dft_payload::extract_ring_payload(&spectrum, passphrase)
740            && let Ok(text) = std::str::from_utf8(&ring_bytes) {
741                let ring_cap = dft_payload::ring_capacity(w, h);
742                return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
743                    mode: crate::stego::frame::MODE_ARMOR,
744                    rs_errors_corrected: 0,
745                    rs_error_capacity: 0,
746                    integrity_percent: 50, // truncated message
747                    repetition_factor: 0,
748                    parity_len: 0,
749                    geometry_corrected: false,
750                    template_peaks_detected: detected.len() as u8,
751                    estimated_rotation_deg: 0.0,
752                    estimated_scale: 1.0,
753                    dft_ring_used: true,
754                    dft_ring_capacity: ring_cap as u16,
755                    fortress_used: false,
756                    signal_strength: 0.0,
757                }));
758            }
759        return Err(StegoError::FrameCorrupted);
760    }
761
762    // P0: Drop spectrum — only needed for template detection and ring extraction above.
763    drop(spectrum);
764
765    // Resample the pixel image to undo the geometric transform.
766    let corrected_pixels = resample::resample_bilinear(
767        &luma_pixels, w, h, &transform, w, h,
768    );
769
770    // P0: Drop luma_pixels — only needed for FFT and resample.
771    drop(luma_pixels);
772
773    // P0: Move img instead of clone — img is not used after correction.
774    let mut corrected_img = img;
775    pixels::luma_f64_to_jpeg(&corrected_pixels, w, h, &mut corrected_img)
776        .ok_or(StegoError::NoLuminanceChannel)?;
777
778    // P0: Drop corrected_pixels — written into corrected_img.
779    drop(corrected_pixels);
780
781    // Retry standard decode on the corrected image (no re-encode/re-parse needed).
782    match try_armor_decode(&corrected_img, passphrase) {
783        Ok((text, mut quality)) => {
784            quality.geometry_corrected = true;
785            quality.template_peaks_detected = detected.len() as u8;
786            quality.estimated_rotation_deg = transform.rotation_rad.to_degrees() as f32;
787            quality.estimated_scale = transform.scale as f32;
788            return Ok((text, quality));
789        }
790        Err(_) => {
791            // STDM decode failed after geometry correction -- try DFT ring
792            // Recompute FFT from corrected image for ring extraction
793            {
794                let (cp, cw, ch) = pixels::jpeg_to_luma_f64(&corrected_img)
795                    .ok_or(StegoError::NoLuminanceChannel)?;
796                let corrected_spectrum = fft2d::fft2d(&cp, cw, ch);
797                if let Some(ring_bytes) = dft_payload::extract_ring_payload(&corrected_spectrum, passphrase)
798                    && let Ok(text) = std::str::from_utf8(&ring_bytes) {
799                        let ring_cap = dft_payload::ring_capacity(cw, ch);
800                        return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
801                            mode: crate::stego::frame::MODE_ARMOR,
802                            rs_errors_corrected: 0,
803                            rs_error_capacity: 0,
804                            integrity_percent: 50,
805                            repetition_factor: 0,
806                            parity_len: 0,
807                            geometry_corrected: true,
808                            template_peaks_detected: detected.len() as u8,
809                            estimated_rotation_deg: transform.rotation_rad.to_degrees() as f32,
810                            estimated_scale: transform.scale as f32,
811                            dft_ring_used: true,
812                            dft_ring_capacity: ring_cap as u16,
813                            fortress_used: false,
814                            signal_strength: 0.0,
815                        }));
816                    }
817            }
818        }
819    }
820
821    Err(StegoError::FrameCorrupted)
822}
823
824/// Extract the 1-byte mean-QT header from embedding units using soft majority voting.
825///
826/// Reads 7 copies x 1 byte x 8 bits = 56 units at the given delta and offset.
827fn extract_header_byte(
828    grid: &DctGrid,
829    positions: &[crate::stego::permute::CoeffPos],
830    vectors: &[[f64; SPREAD_LEN]],
831    delta: f64,
832    offset: usize,
833) -> u8 {
834    let mut header_llrs = [0.0f64; 56]; // 7 copies x 8 bits
835    for i in 0..56 {
836        let unit_idx = offset + i;
837        let group_start = unit_idx * SPREAD_LEN;
838        let group = &positions[group_start..group_start + SPREAD_LEN];
839
840        let mut coeffs = [0.0f64; SPREAD_LEN];
841        for (k, pos) in group.iter().enumerate() {
842            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
843        }
844
845        header_llrs[i] = stdm_extract_soft(&coeffs, &vectors[unit_idx], delta);
846    }
847
848    // Majority vote across 7 copies for each of 8 bits
849    let mut byte = 0u8;
850    for bit_pos in 0..8 {
851        let mut total = 0.0;
852        for copy in 0..7 {
853            total += header_llrs[copy * 8 + bit_pos];
854        }
855        if total < 0.0 {
856            byte |= 1 << (7 - bit_pos);
857        }
858    }
859    byte
860}
861
862/// Phase 1 decode: extract all bits with given delta, then RS decode.
863fn decode_phase1_with_offset(
864    grid: &DctGrid,
865    positions: &[crate::stego::permute::CoeffPos],
866    vectors: &[[f64; SPREAD_LEN]],
867    delta: f64,
868    num_units: usize,
869    payload_offset: usize,
870    passphrase: &str,
871) -> Result<(PayloadData, DecodeQuality), StegoError> {
872    let payload_units = num_units - payload_offset;
873
874    // Extract all LLRs from payload region
875    let mut all_llrs = Vec::with_capacity(payload_units);
876    for unit_idx in payload_offset..num_units {
877        let group_start = unit_idx * SPREAD_LEN;
878        let group = &positions[group_start..group_start + SPREAD_LEN];
879
880        let mut coeffs = [0.0f64; SPREAD_LEN];
881        for (k, pos) in group.iter().enumerate() {
882            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
883        }
884
885        all_llrs.push(stdm_extract_soft(&coeffs, &vectors[unit_idx], delta));
886    }
887
888    // Compute signal strength from raw LLRs (before hard decision)
889    let signal_strength = compute_avg_abs_llr(&all_llrs);
890    // Reference LLR for pristine STDM embedding: delta / 2
891    let reference_llr = delta / 2.0;
892
893    // Convert LLRs to hard bits
894    let extracted_bits: Vec<u8> = all_llrs.iter()
895        .map(|&llr| if llr >= 0.0 { 0 } else { 1 })
896        .collect();
897
898    let extracted_bytes = frame::bits_to_bytes(&extracted_bits);
899    let (decoded_frame, rs_stats) = try_rs_decode_frame(&extracted_bytes)?;
900
901    let parsed = frame::parse_frame(&decoded_frame)?;
902    let plaintext = crypto::decrypt(
903        &parsed.ciphertext,
904        passphrase,
905        &parsed.salt,
906        &parsed.nonce,
907    )?;
908
909    let len = parsed.plaintext_len as usize;
910    if len > plaintext.len() {
911        return Err(StegoError::FrameCorrupted);
912    }
913
914    let payload_data = payload::decode_payload(&plaintext[..len])?;
915    let quality = DecodeQuality::from_rs_stats_with_signal(
916        &rs_stats, 1, ecc::parity_len() as u16, signal_strength, reference_llr,
917    );
918    Ok((payload_data, quality))
919}
920
921
922
923/// Compute distinct candidate r values for a given parity tier and payload capacity.
924pub(super) fn compute_candidate_rs(payload_units: usize, parity: usize) -> Vec<usize> {
925    let mut rs_set = std::collections::BTreeSet::new();
926
927    // Sweep possible frame lengths to find all distinct r values.
928    // rs_encoded_len is monotonically increasing with frame_len, so once
929    // rs_bits exceeds payload_units we can stop.
930    let min_frame = frame::FRAME_OVERHEAD;
931    let max_frame = frame::MAX_FRAME_BYTES;
932
933    for frame_len in min_frame..=max_frame {
934        let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
935        let rs_bits = rs_encoded_len * 8;
936        if rs_bits > payload_units {
937            break;
938        }
939        let r = repetition::compute_r(rs_bits, payload_units);
940        if r >= 3 {
941            rs_set.insert(r);
942        }
943    }
944
945    rs_set.into_iter().collect()
946}
947
948/// Compute candidate repetition factors for fortress compact frames.
949///
950/// Same as `compute_candidate_rs` but uses the compact frame overhead
951/// (22 bytes instead of 50) for minimum frame length.
952pub(super) fn compute_candidate_rs_compact(payload_units: usize, parity: usize) -> Vec<usize> {
953    let mut rs_set = std::collections::BTreeSet::new();
954
955    let min_frame = frame::FORTRESS_COMPACT_FRAME_OVERHEAD;
956    let max_frame = frame::MAX_FRAME_BYTES;
957
958    for frame_len in min_frame..=max_frame {
959        let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
960        let rs_bits = rs_encoded_len * 8;
961        if rs_bits > payload_units {
962            break;
963        }
964        let r = repetition::compute_r(rs_bits, payload_units);
965        if r >= 3 {
966            rs_set.insert(r);
967        }
968    }
969
970    rs_set.into_iter().collect()
971}
972
973/// Maximum LLR cache entries. Each entry can be ~31-65 MB for large images.
974/// P2b: Limit to 5 entries to cap memory at ~155-325 MB instead of unbounded.
975const LLR_CACHE_MAX: usize = 5;
976
977/// Ensure cached LLRs exist for a delta value, extracting from the grid if needed.
978///
979/// P2b: Uses LRU eviction when the cache exceeds `LLR_CACHE_MAX` entries.
980/// The caller reads from the cache snapshot later; no return value needed.
981fn get_or_extract_llrs(
982    cache: &mut Vec<(f64, Vec<f64>)>,
983    delta: f64,
984    grid: &DctGrid,
985    positions: &[crate::stego::permute::CoeffPos],
986    vectors: &[[f64; SPREAD_LEN]],
987    num_units: usize,
988    payload_offset: usize,
989) {
990    // Check cache (use approximate comparison for f64)
991    for i in 0..cache.len() {
992        if (cache[i].0 - delta).abs() < 0.001 {
993            // P2b: Move to end (most recently used) for LRU ordering
994            if i < cache.len() - 1 {
995                let entry = cache.remove(i);
996                cache.push(entry);
997            }
998            return;
999        }
1000    }
1001
1002    // Extract fresh LLRs
1003    let unit_indices: Vec<usize> = (payload_offset..num_units).collect();
1004
1005    let extract_one = |&unit_idx: &usize| -> f64 {
1006        let group_start = unit_idx * SPREAD_LEN;
1007        let group = &positions[group_start..group_start + SPREAD_LEN];
1008
1009        let mut coeffs = [0.0f64; SPREAD_LEN];
1010        for (k, pos) in group.iter().enumerate() {
1011            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
1012        }
1013
1014        stdm_extract_soft(&coeffs, &vectors[unit_idx], delta)
1015    };
1016
1017    #[cfg(feature = "parallel")]
1018    let llrs: Vec<f64> = unit_indices.par_iter().map(extract_one).collect();
1019    #[cfg(not(feature = "parallel"))]
1020    let llrs: Vec<f64> = unit_indices.iter().map(extract_one).collect();
1021
1022    // P2b: Evict oldest entry if cache is full
1023    if cache.len() >= LLR_CACHE_MAX {
1024        cache.remove(0); // Remove LRU (oldest) entry
1025    }
1026
1027    cache.push((delta, llrs));
1028}
1029
1030/// Pre-clamp the Y channel: IDCT -> clamp [0, 255] -> DCT for all blocks.
1031///
1032/// This "settles" the cover image's coefficients so they produce valid pixel
1033/// values. Without this, recompression through a pixel-domain pipeline
1034/// (IDCT -> clamp -> DCT) introduces systematic distortion from clamping.
1035fn pre_clamp_y_channel(img: &mut JpegImage) -> Result<(), StegoError> {
1036    let qt_id = img.frame_info().components[0].quant_table_id as usize;
1037    let qt_values = img.quant_table(qt_id)
1038        .ok_or(StegoError::NoLuminanceChannel)?.values;
1039    let grid = img.dct_grid_mut(0);
1040
1041    let process_block = |chunk: &mut [i16]| {
1042        let quantized: [i16; 64] = chunk.try_into().unwrap();
1043        let mut px = pixels::idct_block(&quantized, &qt_values);
1044        for p in px.iter_mut() {
1045            *p = p.clamp(0.0, 255.0);
1046        }
1047        let settled = pixels::dct_block(&px, &qt_values);
1048        chunk.copy_from_slice(&settled);
1049    };
1050
1051    #[cfg(feature = "parallel")]
1052    grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1053    #[cfg(not(feature = "parallel"))]
1054    grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1055    Ok(())
1056}
1057
1058/// Try to RS-decode a frame from extracted bytes using a specific parity length.
1059///
1060/// Optimized sweep: after the first RS block decodes successfully, the
1061/// `plaintext_len` field (first 2 bytes) determines the exact total frame
1062/// length. Plausibility checks on `plaintext_len` skip obviously wrong
1063/// values before attempting expensive multi-block RS decode.
1064pub(super) fn try_rs_decode_frame_with_parity(
1065    extracted_bytes: &[u8],
1066    parity: usize,
1067) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1068    let k_max = 255 - parity;
1069    let min_data = 2usize.min(k_max);
1070
1071    for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1072        let block_len = data_len + parity;
1073        if block_len > extracted_bytes.len() {
1074            break;
1075        }
1076
1077        if let Ok((first_block_data, first_errors)) =
1078            ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1079            && first_block_data.len() >= 2 {
1080                let pt_len =
1081                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1082
1083                // Plausibility: plaintext_len must be positive.
1084                if pt_len == 0 {
1085                    continue;
1086                }
1087
1088                let ct_len = pt_len + 16;
1089                let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1090
1091                if total_frame_len > frame::MAX_FRAME_BYTES {
1092                    continue;
1093                }
1094
1095                // Plausibility: the RS-encoded frame must fit in extracted data.
1096                let rs_encoded_len =
1097                    ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1098                if rs_encoded_len > extracted_bytes.len() {
1099                    continue;
1100                }
1101
1102                if total_frame_len == data_len {
1103                    let t_max = parity / 2;
1104                    let stats = ecc::RsDecodeStats {
1105                        total_errors: first_errors,
1106                        error_capacity: t_max,
1107                        max_block_errors: first_errors,
1108                        num_blocks: 1,
1109                    };
1110                    return Some((first_block_data, stats));
1111                }
1112
1113                if total_frame_len < data_len {
1114                    let t_max = parity / 2;
1115                    let stats = ecc::RsDecodeStats {
1116                        total_errors: first_errors,
1117                        error_capacity: t_max,
1118                        max_block_errors: first_errors,
1119                        num_blocks: 1,
1120                    };
1121                    return Some((first_block_data[..total_frame_len].to_vec(), stats));
1122                }
1123
1124                if total_frame_len > data_len
1125                    && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1126                        &extracted_bytes[..rs_encoded_len],
1127                        total_frame_len,
1128                        parity,
1129                    ) {
1130                        return Some((decoded, stats));
1131                    }
1132                    // Multi-block decode failed — first-block was a false
1133                    // positive. Continue sweep to try other data_len values.
1134            }
1135    }
1136
1137    None
1138}
1139
1140/// Try to RS-decode a compact fortress frame from extracted bytes.
1141///
1142/// Same logic as `try_rs_decode_frame_with_parity` but uses the compact frame
1143/// overhead (no salt/nonce) when computing the expected total frame length.
1144pub(super) fn try_rs_decode_compact_frame_with_parity(
1145    extracted_bytes: &[u8],
1146    parity: usize,
1147) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1148    let k_max = 255 - parity;
1149    let min_data = 2usize.min(k_max);
1150
1151    for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1152        let block_len = data_len + parity;
1153        if block_len > extracted_bytes.len() {
1154            break;
1155        }
1156
1157        if let Ok((first_block_data, first_errors)) =
1158            ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1159            && first_block_data.len() >= 2 {
1160                let pt_len =
1161                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1162
1163                // Plausibility: plaintext_len must be positive.
1164                if pt_len == 0 {
1165                    continue;
1166                }
1167
1168                let ct_len = pt_len + 16;
1169                // Compact frame: no salt (16) or nonce (12)
1170                let total_frame_len = 2 + ct_len + 4;
1171
1172                if total_frame_len > frame::MAX_FRAME_BYTES {
1173                    continue;
1174                }
1175
1176                // Plausibility: the RS-encoded frame must fit in extracted data.
1177                let rs_encoded_len =
1178                    ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1179                if rs_encoded_len > extracted_bytes.len() {
1180                    continue;
1181                }
1182
1183                if total_frame_len == data_len {
1184                    let t_max = parity / 2;
1185                    let stats = ecc::RsDecodeStats {
1186                        total_errors: first_errors,
1187                        error_capacity: t_max,
1188                        max_block_errors: first_errors,
1189                        num_blocks: 1,
1190                    };
1191                    return Some((first_block_data, stats));
1192                }
1193
1194                if total_frame_len < data_len {
1195                    let t_max = parity / 2;
1196                    let stats = ecc::RsDecodeStats {
1197                        total_errors: first_errors,
1198                        error_capacity: t_max,
1199                        max_block_errors: first_errors,
1200                        num_blocks: 1,
1201                    };
1202                    return Some((first_block_data[..total_frame_len].to_vec(), stats));
1203                }
1204
1205                if total_frame_len > data_len
1206                    && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1207                        &extracted_bytes[..rs_encoded_len],
1208                        total_frame_len,
1209                        parity,
1210                    ) {
1211                        return Some((decoded, stats));
1212                    }
1213                    // Multi-block decode failed — first-block was a false
1214                    // positive. Continue sweep to try other data_len values.
1215            }
1216    }
1217
1218    None
1219}
1220
1221/// Try to RS-decode a frame from extracted bytes (Phase 1 path, fixed parity=64).
1222fn try_rs_decode_frame(extracted_bytes: &[u8]) -> Result<(Vec<u8>, ecc::RsDecodeStats), StegoError> {
1223    let parity = ecc::parity_len();
1224
1225    let min_data = crate::stego::frame::FRAME_OVERHEAD;
1226    let max_first_block_data = 191usize; // K_DEFAULT
1227
1228    for data_len in min_data..=max_first_block_data.min(extracted_bytes.len().saturating_sub(parity))
1229    {
1230        let block_len = data_len + parity;
1231        if block_len > extracted_bytes.len() {
1232            break;
1233        }
1234
1235        if let Ok((first_block_data, first_errors)) = ecc::rs_decode(&extracted_bytes[..block_len], data_len)
1236            && first_block_data.len() >= 2 {
1237                let pt_len =
1238                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1239
1240                // Plausibility: plaintext_len must be positive.
1241                if pt_len == 0 {
1242                    continue;
1243                }
1244
1245                let ct_len = pt_len + 16;
1246                let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1247
1248                if total_frame_len > frame::MAX_FRAME_BYTES {
1249                    continue;
1250                }
1251
1252                // Plausibility: the RS-encoded frame must fit in extracted data.
1253                let rs_encoded_len = ecc::rs_encoded_len(total_frame_len);
1254                if rs_encoded_len > extracted_bytes.len() {
1255                    continue;
1256                }
1257
1258                if total_frame_len <= data_len {
1259                    let stats = ecc::RsDecodeStats {
1260                        total_errors: first_errors,
1261                        error_capacity: ecc::T_MAX,
1262                        max_block_errors: first_errors,
1263                        num_blocks: 1,
1264                    };
1265                    // Single block: truncate to exact frame length if needed.
1266                    let frame_data = if total_frame_len == data_len {
1267                        first_block_data
1268                    } else {
1269                        first_block_data[..total_frame_len].to_vec()
1270                    };
1271                    return Ok((frame_data, stats));
1272                }
1273
1274                // total_frame_len > data_len: multi-block RS decode
1275                if let Ok((decoded, stats)) = ecc::rs_decode_blocks(
1276                    &extracted_bytes[..rs_encoded_len],
1277                    total_frame_len,
1278                ) {
1279                    return Ok((decoded, stats));
1280                }
1281                // Multi-block decode failed — first-block was a false
1282                // positive. Continue sweep to try other data_len values.
1283            }
1284    }
1285
1286    Err(StegoError::FrameCorrupted)
1287}
1288
1289// --- DctGrid flat access helpers ---
1290
1291/// Read a coefficient from a `DctGrid` using a flat index.
1292fn flat_get(grid: &DctGrid, flat_idx: usize) -> i16 {
1293    let bw = grid.blocks_wide();
1294    let block_idx = flat_idx / 64;
1295    let pos = flat_idx % 64;
1296    let br = block_idx / bw;
1297    let bc = block_idx % bw;
1298    let i = pos / 8;
1299    let j = pos % 8;
1300    grid.get(br, bc, i, j)
1301}
1302
1303/// Write a coefficient into a `DctGrid` using a flat index.
1304fn flat_set(grid: &mut DctGrid, flat_idx: usize, val: i16) {
1305    let bw = grid.blocks_wide();
1306    let block_idx = flat_idx / 64;
1307    let pos = flat_idx % 64;
1308    let br = block_idx / bw;
1309    let bc = block_idx % bw;
1310    let i = pos / 8;
1311    let j = pos % 8;
1312    grid.set(br, bc, i, j, val);
1313}
1314
1315/// Dual-tier capacity information for Armor mode.
1316///
1317/// Reports both Fortress (BA-QIM) and STDM capacities so the UI can show
1318/// which sub-mode will be used for the current message length.
1319#[derive(Debug, Clone)]
1320pub struct ArmorCapacityInfo {
1321    /// Maximum plaintext bytes embeddable via Fortress (BA-QIM). 0 if image too small.
1322    pub fortress_capacity: usize,
1323    /// Maximum plaintext bytes embeddable via STDM (standard Armor).
1324    pub stdm_capacity: usize,
1325}
1326
1327/// Compute dual-tier capacity information for an Armor-mode image.
1328///
1329/// Returns both Fortress and STDM capacities. The existing `armor_capacity()`
1330/// function continues to return `stdm_capacity` for backward compatibility.
1331pub fn armor_capacity_info(jpeg_bytes: &[u8]) -> Result<ArmorCapacityInfo, StegoError> {
1332    let img = JpegImage::from_bytes(jpeg_bytes)?;
1333
1334    if img.num_components() == 0 {
1335        return Err(StegoError::NoLuminanceChannel);
1336    }
1337
1338    let fortress_cap = fortress::fortress_capacity(&img).unwrap_or(0);
1339    let stdm_cap = super::capacity::estimate_armor_capacity(&img).unwrap_or(0);
1340
1341    Ok(ArmorCapacityInfo {
1342        fortress_capacity: fortress_cap,
1343        stdm_capacity: stdm_cap,
1344    })
1345}
1346
1347/// Embed a DFT template and ring payload into the luminance channel.
1348///
1349/// Embeds both template peaks (for geometry resilience) and the DFT ring
1350/// payload (resize-robust second layer) in the same FFT pass.
1351fn embed_dft_template(img: &mut JpegImage, passphrase: &str, message: &str) -> Result<(), StegoError> {
1352    let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(img)
1353        .ok_or(StegoError::NoLuminanceChannel)?;
1354
1355    // P0: Drop luma_pixels after FFT — only needed to produce the spectrum.
1356    let mut spectrum = fft2d::fft2d(&luma_pixels, w, h);
1357    drop(luma_pixels);
1358
1359    // Template peaks for geometry estimation
1360    let peaks = template::generate_template_peaks(passphrase, w, h)?;
1361    template::embed_template(&mut spectrum, &peaks);
1362
1363    // Ring payload -- truncate message to ring capacity
1364    use crate::stego::armor::dft_payload;
1365    let ring_cap = dft_payload::ring_capacity(w, h);
1366    if ring_cap > 0 && !message.is_empty() {
1367        // Truncate at a valid UTF-8 character boundary to avoid splitting
1368        // multi-byte characters (e.g., emoji, CJK, accented chars).
1369        let max_byte = message.len().min(ring_cap);
1370        let truncated_len = message[..max_byte]
1371            .char_indices()
1372            .last()
1373            .map_or(0, |(i, c)| i + c.len_utf8());
1374        let truncated = &message.as_bytes()[..truncated_len];
1375        dft_payload::embed_ring_payload(&mut spectrum, truncated, passphrase)?;
1376    }
1377
1378    // P0: Drop spectrum after IFFT — only needed to produce the modified pixels.
1379    let modified = fft2d::ifft2d(&spectrum);
1380    drop(spectrum);
1381
1382    pixels::luma_f64_to_jpeg(&modified, w, h, img)
1383        .ok_or(StegoError::NoLuminanceChannel)?;
1384    Ok(())
1385}
1386
1387// --- Fortress QF75 pre-settlement ---
1388
1389/// Standard JPEG luminance quantization table (Table K.1 from the JPEG spec).
1390/// These are the base values before quality-factor scaling.
1391/// NOTE: Duplicates STD_LUMA_QT in selection.rs — kept here to avoid coupling
1392/// the Fortress pre-settlement path to the stability-map module.
1393const JPEG_BASE_LUMINANCE_QT: [u16; 64] = [
1394    16, 11, 10, 16,  24,  40,  51,  61,
1395    12, 12, 14, 19,  26,  58,  60,  55,
1396    14, 13, 16, 24,  40,  57,  69,  56,
1397    14, 17, 22, 29,  51,  87,  80,  62,
1398    18, 22, 37, 56,  68, 109, 103,  77,
1399    24, 35, 55, 64,  81, 104, 113,  92,
1400    49, 64, 78, 87, 103, 121, 120, 101,
1401    72, 92, 95, 98, 112, 100, 103,  99,
1402];
1403
1404/// Standard JPEG chrominance quantization table (Table K.2 from the JPEG spec).
1405/// NOTE: Duplicates STD_CHROMA_QT in selection.rs — kept here for the same reason.
1406const JPEG_BASE_CHROMINANCE_QT: [u16; 64] = [
1407    17, 18, 24, 47, 99, 99, 99, 99,
1408    18, 21, 26, 66, 99, 99, 99, 99,
1409    24, 26, 56, 99, 99, 99, 99, 99,
1410    47, 66, 99, 99, 99, 99, 99, 99,
1411    99, 99, 99, 99, 99, 99, 99, 99,
1412    99, 99, 99, 99, 99, 99, 99, 99,
1413    99, 99, 99, 99, 99, 99, 99, 99,
1414    99, 99, 99, 99, 99, 99, 99, 99,
1415];
1416
1417/// Compute a JPEG quantization table for a given quality factor (1-100).
1418///
1419/// Uses the standard libjpeg scaling formula:
1420/// - QF >= 50: scale = 200 - 2 * QF
1421/// - QF <  50: scale = 5000 / QF
1422///
1423/// NOTE: Duplicates scale_quant_table() in selection.rs — kept here to avoid
1424/// coupling the Fortress pre-settlement path to the stability-map module.
1425fn compute_jpeg_qt(base: &[u16; 64], qf: u32) -> [u16; 64] {
1426    let scale = if qf >= 50 { 200 - 2 * qf } else { 5000 / qf };
1427    let mut qt = [0u16; 64];
1428    for i in 0..64 {
1429        let val = (base[i] as u32 * scale + 50) / 100;
1430        qt[i] = val.clamp(1, 255) as u16;
1431    }
1432    qt
1433}
1434
1435/// Pre-settle all image components to QF75 quantization tables.
1436///
1437/// For each component, performs IDCT → pixel clamp → DCT with QF75 QT tables,
1438/// then replaces the component's quantization table. This makes the coefficients
1439/// (especially Y-channel DCs used by Fortress) nearly idempotent under Q75
1440/// recompression, which is what WhatsApp and most social media platforms use.
1441///
1442/// Without this, platform-side quality settings vary wildly (iOS 0.65 ≈ Q90,
1443/// Android Q70, Web 0.65 ≈ varies), leaving coefficients on a fine grid that
1444/// shifts dramatically when recompressed to Q75.
1445fn pre_settle_for_fortress(img: &mut JpegImage) -> Result<(), StegoError> {
1446    use crate::codec::jpeg::dct::QuantTable;
1447
1448    let num_components = img.num_components();
1449    let target_qf = 75u32;
1450
1451    // Pre-compute target QT for each QT slot (luminance vs chrominance base).
1452    // We must re-quantize ALL components, even those sharing a QT ID,
1453    // because each component has its own DctGrid with separate coefficients.
1454    let mut new_qts: Vec<(usize, [u16; 64], [u16; 64])> = Vec::new(); // (qt_id, old, new)
1455
1456    for comp_idx in 0..num_components {
1457        let qt_id = img.frame_info().components[comp_idx].quant_table_id as usize;
1458        let old_qt = img.quant_table(qt_id)
1459            .ok_or(StegoError::NoLuminanceChannel)?.values;
1460        let base = if comp_idx == 0 {
1461            &JPEG_BASE_LUMINANCE_QT
1462        } else {
1463            &JPEG_BASE_CHROMINANCE_QT
1464        };
1465        let new_qt = compute_jpeg_qt(base, target_qf);
1466
1467        // Re-quantize all blocks in this component — parallel when available.
1468        let grid = img.dct_grid_mut(comp_idx);
1469
1470        let process_block = |chunk: &mut [i16]| {
1471            let quantized: [i16; 64] = chunk.try_into().unwrap();
1472            let mut px = pixels::idct_block(&quantized, &old_qt);
1473            for p in px.iter_mut() {
1474                *p = p.clamp(0.0, 255.0);
1475            }
1476            let settled = pixels::dct_block(&px, &new_qt);
1477            chunk.copy_from_slice(&settled);
1478        };
1479
1480        #[cfg(feature = "parallel")]
1481        grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1482        #[cfg(not(feature = "parallel"))]
1483        grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1484
1485        new_qts.push((qt_id, old_qt, new_qt));
1486    }
1487
1488    // Replace quantization tables (deduplicate by QT ID)
1489    let mut replaced = [false; 4];
1490    for (qt_id, _old, new_qt) in &new_qts {
1491        if !replaced[*qt_id] {
1492            img.set_quant_table(*qt_id, QuantTable::new(*new_qt));
1493            replaced[*qt_id] = true;
1494        }
1495    }
1496    Ok(())
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::*;
1502
1503    #[test]
1504    fn compute_integrity_pristine() {
1505        // Pristine: signal_strength == reference, 0 RS errors
1506        let stats = ecc::RsDecodeStats {
1507            total_errors: 0,
1508            error_capacity: 32,
1509            max_block_errors: 0,
1510            num_blocks: 1,
1511        };
1512        let integrity = compute_integrity(15.0, &stats, 15.0);
1513        // 0.7 * 1.0 + 0.3 * 1.0 = 1.0 → 100
1514        assert_eq!(integrity, 100);
1515    }
1516
1517    #[test]
1518    fn compute_integrity_half_signal() {
1519        // Signal is half the reference, no RS errors
1520        let stats = ecc::RsDecodeStats {
1521            total_errors: 0,
1522            error_capacity: 32,
1523            max_block_errors: 0,
1524            num_blocks: 1,
1525        };
1526        let integrity = compute_integrity(7.5, &stats, 15.0);
1527        // 0.7 * 0.5 + 0.3 * 1.0 = 0.65 → 65
1528        assert_eq!(integrity, 65);
1529    }
1530
1531    #[test]
1532    fn compute_integrity_zero_signal() {
1533        // No signal, no RS errors
1534        let stats = ecc::RsDecodeStats {
1535            total_errors: 0,
1536            error_capacity: 32,
1537            max_block_errors: 0,
1538            num_blocks: 1,
1539        };
1540        let integrity = compute_integrity(0.0, &stats, 15.0);
1541        // 0.7 * 0.0 + 0.3 * 1.0 = 0.3 → 30
1542        assert_eq!(integrity, 30);
1543    }
1544
1545    #[test]
1546    fn compute_integrity_with_rs_errors() {
1547        // Full signal, half RS capacity used
1548        let stats = ecc::RsDecodeStats {
1549            total_errors: 16,
1550            error_capacity: 32,
1551            max_block_errors: 16,
1552            num_blocks: 1,
1553        };
1554        let integrity = compute_integrity(15.0, &stats, 15.0);
1555        // 0.7 * 1.0 + 0.3 * 0.5 = 0.85 → 85
1556        assert_eq!(integrity, 85);
1557    }
1558
1559    #[test]
1560    fn compute_integrity_both_degraded() {
1561        // Half signal, half RS capacity used
1562        let stats = ecc::RsDecodeStats {
1563            total_errors: 16,
1564            error_capacity: 32,
1565            max_block_errors: 16,
1566            num_blocks: 1,
1567        };
1568        let integrity = compute_integrity(7.5, &stats, 15.0);
1569        // 0.7 * 0.5 + 0.3 * 0.5 = 0.5 → 50
1570        assert_eq!(integrity, 50);
1571    }
1572
1573    #[test]
1574    fn compute_integrity_signal_exceeds_reference() {
1575        // Signal > reference (clamped to 1.0)
1576        let stats = ecc::RsDecodeStats {
1577            total_errors: 0,
1578            error_capacity: 32,
1579            max_block_errors: 0,
1580            num_blocks: 1,
1581        };
1582        let integrity = compute_integrity(20.0, &stats, 15.0);
1583        // 0.7 * 1.0 + 0.3 * 1.0 = 1.0 → 100 (clamped)
1584        assert_eq!(integrity, 100);
1585    }
1586
1587    #[test]
1588    fn compute_integrity_zero_reference() {
1589        // Edge case: reference_llr = 0 → llr_score defaults to 1.0
1590        let stats = ecc::RsDecodeStats {
1591            total_errors: 0,
1592            error_capacity: 0,
1593            max_block_errors: 0,
1594            num_blocks: 0,
1595        };
1596        let integrity = compute_integrity(5.0, &stats, 0.0);
1597        // llr_score = 1.0 (fallback), rs_score = 1.0 (error_capacity == 0)
1598        assert_eq!(integrity, 100);
1599    }
1600
1601    #[test]
1602    fn compute_avg_abs_llr_basic() {
1603        let llrs = vec![5.0, -3.0, 4.0, -2.0];
1604        let avg = compute_avg_abs_llr(&llrs);
1605        // (5 + 3 + 4 + 2) / 4 = 3.5
1606        assert!((avg - 3.5).abs() < 1e-10);
1607    }
1608
1609    #[test]
1610    fn compute_avg_abs_llr_empty() {
1611        assert_eq!(compute_avg_abs_llr(&[]), 0.0);
1612    }
1613
1614    #[test]
1615    fn decode_quality_ghost_unchanged() {
1616        let q = DecodeQuality::ghost();
1617        assert_eq!(q.integrity_percent, 100, "Ghost always 100%");
1618        assert_eq!(q.signal_strength, 0.0);
1619    }
1620
1621    #[test]
1622    fn decode_quality_from_rs_stats_with_signal_pristine() {
1623        let stats = ecc::RsDecodeStats {
1624            total_errors: 0,
1625            error_capacity: 32,
1626            max_block_errors: 0,
1627            num_blocks: 1,
1628        };
1629        let q = DecodeQuality::from_rs_stats_with_signal(&stats, 5, 64, 15.0, 15.0);
1630        assert_eq!(q.integrity_percent, 100);
1631        assert!((q.signal_strength - 15.0).abs() < 1e-10);
1632        assert_eq!(q.repetition_factor, 5);
1633        assert_eq!(q.parity_len, 64);
1634    }
1635
1636}