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).
699#[allow(dead_code)] // Reserved for parallel smart_decode path (deferred wiring).
700pub(crate) fn armor_decode_no_fortress(img: &JpegImage, stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
701    match try_armor_decode(img, passphrase) {
702        Ok(result) => Ok(result),
703        Err(_stdm_err) => {
704            progress::check_cancelled()?;
705            match try_geometric_recovery(stego_bytes, passphrase) {
706                Ok(result) => Ok(result),
707                Err(_) => Err(_stdm_err),
708            }
709        }
710    }
711}
712
713/// Phase 3 geometric recovery: detect DFT template, estimate transform, resample, retry.
714/// Also tries DFT ring payload extraction as fallback.
715pub(crate) fn try_geometric_recovery(stego_bytes: &[u8], passphrase: &str) -> Result<(PayloadData, DecodeQuality), StegoError> {
716    use crate::stego::armor::dft_payload;
717
718    let img = JpegImage::from_bytes(stego_bytes)?;
719
720    if img.num_components() == 0 {
721        return Err(StegoError::NoLuminanceChannel);
722    }
723
724    // Convert to pixel domain and compute 2D FFT.
725    let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(&img)
726        .ok_or(StegoError::NoLuminanceChannel)?;
727    let spectrum = fft2d::fft2d(&luma_pixels, w, h);
728
729    // Generate expected template peaks and search for them.
730    let peaks = template::generate_template_peaks(passphrase, w, h)?;
731    let detected = template::detect_template(&spectrum, &peaks);
732
733    // Estimate the geometric transform from detected peaks.
734    let transform = template::estimate_transform(&detected)
735        .ok_or(StegoError::FrameCorrupted)?;
736
737    // Skip if transform is essentially identity (fast path would have worked).
738    if transform.rotation_rad.abs() < 0.001 && (transform.scale - 1.0).abs() < 0.001 {
739        // Try DFT ring extraction directly (no geometry correction needed)
740        if let Some(ring_bytes) = dft_payload::extract_ring_payload(&spectrum, passphrase)
741            && let Ok(text) = std::str::from_utf8(&ring_bytes) {
742                let ring_cap = dft_payload::ring_capacity(w, h);
743                return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
744                    mode: crate::stego::frame::MODE_ARMOR,
745                    rs_errors_corrected: 0,
746                    rs_error_capacity: 0,
747                    integrity_percent: 50, // truncated message
748                    repetition_factor: 0,
749                    parity_len: 0,
750                    geometry_corrected: false,
751                    template_peaks_detected: detected.len() as u8,
752                    estimated_rotation_deg: 0.0,
753                    estimated_scale: 1.0,
754                    dft_ring_used: true,
755                    dft_ring_capacity: ring_cap as u16,
756                    fortress_used: false,
757                    signal_strength: 0.0,
758                }));
759            }
760        return Err(StegoError::FrameCorrupted);
761    }
762
763    // P0: Drop spectrum — only needed for template detection and ring extraction above.
764    drop(spectrum);
765
766    // Resample the pixel image to undo the geometric transform.
767    let corrected_pixels = resample::resample_bilinear(
768        &luma_pixels, w, h, &transform, w, h,
769    );
770
771    // P0: Drop luma_pixels — only needed for FFT and resample.
772    drop(luma_pixels);
773
774    // P0: Move img instead of clone — img is not used after correction.
775    let mut corrected_img = img;
776    pixels::luma_f64_to_jpeg(&corrected_pixels, w, h, &mut corrected_img)
777        .ok_or(StegoError::NoLuminanceChannel)?;
778
779    // P0: Drop corrected_pixels — written into corrected_img.
780    drop(corrected_pixels);
781
782    // Retry standard decode on the corrected image (no re-encode/re-parse needed).
783    match try_armor_decode(&corrected_img, passphrase) {
784        Ok((text, mut quality)) => {
785            quality.geometry_corrected = true;
786            quality.template_peaks_detected = detected.len() as u8;
787            quality.estimated_rotation_deg = transform.rotation_rad.to_degrees() as f32;
788            quality.estimated_scale = transform.scale as f32;
789            return Ok((text, quality));
790        }
791        Err(_) => {
792            // STDM decode failed after geometry correction -- try DFT ring
793            // Recompute FFT from corrected image for ring extraction
794            {
795                let (cp, cw, ch) = pixels::jpeg_to_luma_f64(&corrected_img)
796                    .ok_or(StegoError::NoLuminanceChannel)?;
797                let corrected_spectrum = fft2d::fft2d(&cp, cw, ch);
798                if let Some(ring_bytes) = dft_payload::extract_ring_payload(&corrected_spectrum, passphrase)
799                    && let Ok(text) = std::str::from_utf8(&ring_bytes) {
800                        let ring_cap = dft_payload::ring_capacity(cw, ch);
801                        return Ok((PayloadData { text: text.to_string(), files: vec![] }, DecodeQuality {
802                            mode: crate::stego::frame::MODE_ARMOR,
803                            rs_errors_corrected: 0,
804                            rs_error_capacity: 0,
805                            integrity_percent: 50,
806                            repetition_factor: 0,
807                            parity_len: 0,
808                            geometry_corrected: true,
809                            template_peaks_detected: detected.len() as u8,
810                            estimated_rotation_deg: transform.rotation_rad.to_degrees() as f32,
811                            estimated_scale: transform.scale as f32,
812                            dft_ring_used: true,
813                            dft_ring_capacity: ring_cap as u16,
814                            fortress_used: false,
815                            signal_strength: 0.0,
816                        }));
817                    }
818            }
819        }
820    }
821
822    Err(StegoError::FrameCorrupted)
823}
824
825/// Extract the 1-byte mean-QT header from embedding units using soft majority voting.
826///
827/// Reads 7 copies x 1 byte x 8 bits = 56 units at the given delta and offset.
828fn extract_header_byte(
829    grid: &DctGrid,
830    positions: &[crate::stego::permute::CoeffPos],
831    vectors: &[[f64; SPREAD_LEN]],
832    delta: f64,
833    offset: usize,
834) -> u8 {
835    let mut header_llrs = [0.0f64; 56]; // 7 copies x 8 bits
836    for i in 0..56 {
837        let unit_idx = offset + i;
838        let group_start = unit_idx * SPREAD_LEN;
839        let group = &positions[group_start..group_start + SPREAD_LEN];
840
841        let mut coeffs = [0.0f64; SPREAD_LEN];
842        for (k, pos) in group.iter().enumerate() {
843            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
844        }
845
846        header_llrs[i] = stdm_extract_soft(&coeffs, &vectors[unit_idx], delta);
847    }
848
849    // Majority vote across 7 copies for each of 8 bits
850    let mut byte = 0u8;
851    for bit_pos in 0..8 {
852        let mut total = 0.0;
853        for copy in 0..7 {
854            total += header_llrs[copy * 8 + bit_pos];
855        }
856        if total < 0.0 {
857            byte |= 1 << (7 - bit_pos);
858        }
859    }
860    byte
861}
862
863/// Phase 1 decode: extract all bits with given delta, then RS decode.
864fn decode_phase1_with_offset(
865    grid: &DctGrid,
866    positions: &[crate::stego::permute::CoeffPos],
867    vectors: &[[f64; SPREAD_LEN]],
868    delta: f64,
869    num_units: usize,
870    payload_offset: usize,
871    passphrase: &str,
872) -> Result<(PayloadData, DecodeQuality), StegoError> {
873    let payload_units = num_units - payload_offset;
874
875    // Extract all LLRs from payload region
876    let mut all_llrs = Vec::with_capacity(payload_units);
877    for unit_idx in payload_offset..num_units {
878        let group_start = unit_idx * SPREAD_LEN;
879        let group = &positions[group_start..group_start + SPREAD_LEN];
880
881        let mut coeffs = [0.0f64; SPREAD_LEN];
882        for (k, pos) in group.iter().enumerate() {
883            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
884        }
885
886        all_llrs.push(stdm_extract_soft(&coeffs, &vectors[unit_idx], delta));
887    }
888
889    // Compute signal strength from raw LLRs (before hard decision)
890    let signal_strength = compute_avg_abs_llr(&all_llrs);
891    // Reference LLR for pristine STDM embedding: delta / 2
892    let reference_llr = delta / 2.0;
893
894    // Convert LLRs to hard bits
895    let extracted_bits: Vec<u8> = all_llrs.iter()
896        .map(|&llr| if llr >= 0.0 { 0 } else { 1 })
897        .collect();
898
899    let extracted_bytes = frame::bits_to_bytes(&extracted_bits);
900    let (decoded_frame, rs_stats) = try_rs_decode_frame(&extracted_bytes)?;
901
902    let parsed = frame::parse_frame(&decoded_frame)?;
903    let plaintext = crypto::decrypt(
904        &parsed.ciphertext,
905        passphrase,
906        &parsed.salt,
907        &parsed.nonce,
908    )?;
909
910    let len = parsed.plaintext_len as usize;
911    if len > plaintext.len() {
912        return Err(StegoError::FrameCorrupted);
913    }
914
915    let payload_data = payload::decode_payload(&plaintext[..len])?;
916    let quality = DecodeQuality::from_rs_stats_with_signal(
917        &rs_stats, 1, ecc::parity_len() as u16, signal_strength, reference_llr,
918    );
919    Ok((payload_data, quality))
920}
921
922
923
924/// Compute distinct candidate r values for a given parity tier and payload capacity.
925pub(super) fn compute_candidate_rs(payload_units: usize, parity: usize) -> Vec<usize> {
926    let mut rs_set = std::collections::BTreeSet::new();
927
928    // Sweep possible frame lengths to find all distinct r values.
929    // rs_encoded_len is monotonically increasing with frame_len, so once
930    // rs_bits exceeds payload_units we can stop.
931    let min_frame = frame::FRAME_OVERHEAD;
932    let max_frame = frame::MAX_FRAME_BYTES;
933
934    for frame_len in min_frame..=max_frame {
935        let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
936        let rs_bits = rs_encoded_len * 8;
937        if rs_bits > payload_units {
938            break;
939        }
940        let r = repetition::compute_r(rs_bits, payload_units);
941        if r >= 3 {
942            rs_set.insert(r);
943        }
944    }
945
946    rs_set.into_iter().collect()
947}
948
949/// Compute candidate repetition factors for fortress compact frames.
950///
951/// Same as `compute_candidate_rs` but uses the compact frame overhead
952/// (22 bytes instead of 50) for minimum frame length.
953pub(super) fn compute_candidate_rs_compact(payload_units: usize, parity: usize) -> Vec<usize> {
954    let mut rs_set = std::collections::BTreeSet::new();
955
956    let min_frame = frame::FORTRESS_COMPACT_FRAME_OVERHEAD;
957    let max_frame = frame::MAX_FRAME_BYTES;
958
959    for frame_len in min_frame..=max_frame {
960        let rs_encoded_len = ecc::rs_encoded_len_with_parity(frame_len, parity);
961        let rs_bits = rs_encoded_len * 8;
962        if rs_bits > payload_units {
963            break;
964        }
965        let r = repetition::compute_r(rs_bits, payload_units);
966        if r >= 3 {
967            rs_set.insert(r);
968        }
969    }
970
971    rs_set.into_iter().collect()
972}
973
974/// Maximum LLR cache entries. Each entry can be ~31-65 MB for large images.
975/// P2b: Limit to 5 entries to cap memory at ~155-325 MB instead of unbounded.
976const LLR_CACHE_MAX: usize = 5;
977
978/// Ensure cached LLRs exist for a delta value, extracting from the grid if needed.
979///
980/// P2b: Uses LRU eviction when the cache exceeds `LLR_CACHE_MAX` entries.
981/// The caller reads from the cache snapshot later; no return value needed.
982fn get_or_extract_llrs(
983    cache: &mut Vec<(f64, Vec<f64>)>,
984    delta: f64,
985    grid: &DctGrid,
986    positions: &[crate::stego::permute::CoeffPos],
987    vectors: &[[f64; SPREAD_LEN]],
988    num_units: usize,
989    payload_offset: usize,
990) {
991    // Check cache (use approximate comparison for f64)
992    for i in 0..cache.len() {
993        if (cache[i].0 - delta).abs() < 0.001 {
994            // P2b: Move to end (most recently used) for LRU ordering
995            if i < cache.len() - 1 {
996                let entry = cache.remove(i);
997                cache.push(entry);
998            }
999            return;
1000        }
1001    }
1002
1003    // Extract fresh LLRs
1004    let unit_indices: Vec<usize> = (payload_offset..num_units).collect();
1005
1006    let extract_one = |&unit_idx: &usize| -> f64 {
1007        let group_start = unit_idx * SPREAD_LEN;
1008        let group = &positions[group_start..group_start + SPREAD_LEN];
1009
1010        let mut coeffs = [0.0f64; SPREAD_LEN];
1011        for (k, pos) in group.iter().enumerate() {
1012            coeffs[k] = flat_get(grid, pos.flat_idx as usize) as f64;
1013        }
1014
1015        stdm_extract_soft(&coeffs, &vectors[unit_idx], delta)
1016    };
1017
1018    #[cfg(feature = "parallel")]
1019    let llrs: Vec<f64> = unit_indices.par_iter().map(extract_one).collect();
1020    #[cfg(not(feature = "parallel"))]
1021    let llrs: Vec<f64> = unit_indices.iter().map(extract_one).collect();
1022
1023    // P2b: Evict oldest entry if cache is full
1024    if cache.len() >= LLR_CACHE_MAX {
1025        cache.remove(0); // Remove LRU (oldest) entry
1026    }
1027
1028    cache.push((delta, llrs));
1029}
1030
1031/// Pre-clamp the Y channel: IDCT -> clamp [0, 255] -> DCT for all blocks.
1032///
1033/// This "settles" the cover image's coefficients so they produce valid pixel
1034/// values. Without this, recompression through a pixel-domain pipeline
1035/// (IDCT -> clamp -> DCT) introduces systematic distortion from clamping.
1036fn pre_clamp_y_channel(img: &mut JpegImage) -> Result<(), StegoError> {
1037    let qt_id = img.frame_info().components[0].quant_table_id as usize;
1038    let qt_values = img.quant_table(qt_id)
1039        .ok_or(StegoError::NoLuminanceChannel)?.values;
1040    let grid = img.dct_grid_mut(0);
1041
1042    let process_block = |chunk: &mut [i16]| {
1043        let quantized: [i16; 64] = chunk.try_into().unwrap();
1044        let mut px = pixels::idct_block(&quantized, &qt_values);
1045        for p in px.iter_mut() {
1046            *p = p.clamp(0.0, 255.0);
1047        }
1048        let settled = pixels::dct_block(&px, &qt_values);
1049        chunk.copy_from_slice(&settled);
1050    };
1051
1052    #[cfg(feature = "parallel")]
1053    grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1054    #[cfg(not(feature = "parallel"))]
1055    grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1056    Ok(())
1057}
1058
1059/// Try to RS-decode a frame from extracted bytes using a specific parity length.
1060///
1061/// Optimized sweep: after the first RS block decodes successfully, the
1062/// `plaintext_len` field (first 2 bytes) determines the exact total frame
1063/// length. Plausibility checks on `plaintext_len` skip obviously wrong
1064/// values before attempting expensive multi-block RS decode.
1065pub(super) fn try_rs_decode_frame_with_parity(
1066    extracted_bytes: &[u8],
1067    parity: usize,
1068) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1069    let k_max = 255 - parity;
1070    let min_data = 2usize.min(k_max);
1071
1072    for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1073        let block_len = data_len + parity;
1074        if block_len > extracted_bytes.len() {
1075            break;
1076        }
1077
1078        if let Ok((first_block_data, first_errors)) =
1079            ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1080            && first_block_data.len() >= 2 {
1081                let pt_len =
1082                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1083
1084                // Plausibility: plaintext_len must be positive.
1085                if pt_len == 0 {
1086                    continue;
1087                }
1088
1089                let ct_len = pt_len + 16;
1090                let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1091
1092                if total_frame_len > frame::MAX_FRAME_BYTES {
1093                    continue;
1094                }
1095
1096                // Plausibility: the RS-encoded frame must fit in extracted data.
1097                let rs_encoded_len =
1098                    ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1099                if rs_encoded_len > extracted_bytes.len() {
1100                    continue;
1101                }
1102
1103                if total_frame_len == data_len {
1104                    let t_max = parity / 2;
1105                    let stats = ecc::RsDecodeStats {
1106                        total_errors: first_errors,
1107                        error_capacity: t_max,
1108                        max_block_errors: first_errors,
1109                        num_blocks: 1,
1110                    };
1111                    return Some((first_block_data, stats));
1112                }
1113
1114                if total_frame_len < data_len {
1115                    let t_max = parity / 2;
1116                    let stats = ecc::RsDecodeStats {
1117                        total_errors: first_errors,
1118                        error_capacity: t_max,
1119                        max_block_errors: first_errors,
1120                        num_blocks: 1,
1121                    };
1122                    return Some((first_block_data[..total_frame_len].to_vec(), stats));
1123                }
1124
1125                if total_frame_len > data_len
1126                    && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1127                        &extracted_bytes[..rs_encoded_len],
1128                        total_frame_len,
1129                        parity,
1130                    ) {
1131                        return Some((decoded, stats));
1132                    }
1133                    // Multi-block decode failed — first-block was a false
1134                    // positive. Continue sweep to try other data_len values.
1135            }
1136    }
1137
1138    None
1139}
1140
1141/// Try to RS-decode a compact fortress frame from extracted bytes.
1142///
1143/// Same logic as `try_rs_decode_frame_with_parity` but uses the compact frame
1144/// overhead (no salt/nonce) when computing the expected total frame length.
1145pub(super) fn try_rs_decode_compact_frame_with_parity(
1146    extracted_bytes: &[u8],
1147    parity: usize,
1148) -> Option<(Vec<u8>, ecc::RsDecodeStats)> {
1149    let k_max = 255 - parity;
1150    let min_data = 2usize.min(k_max);
1151
1152    for data_len in min_data..=k_max.min(extracted_bytes.len().saturating_sub(parity)) {
1153        let block_len = data_len + parity;
1154        if block_len > extracted_bytes.len() {
1155            break;
1156        }
1157
1158        if let Ok((first_block_data, first_errors)) =
1159            ecc::rs_decode_with_parity(&extracted_bytes[..block_len], data_len, parity)
1160            && first_block_data.len() >= 2 {
1161                let pt_len =
1162                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1163
1164                // Plausibility: plaintext_len must be positive.
1165                if pt_len == 0 {
1166                    continue;
1167                }
1168
1169                let ct_len = pt_len + 16;
1170                // Compact frame: no salt (16) or nonce (12)
1171                let total_frame_len = 2 + ct_len + 4;
1172
1173                if total_frame_len > frame::MAX_FRAME_BYTES {
1174                    continue;
1175                }
1176
1177                // Plausibility: the RS-encoded frame must fit in extracted data.
1178                let rs_encoded_len =
1179                    ecc::rs_encoded_len_with_parity(total_frame_len, parity);
1180                if rs_encoded_len > extracted_bytes.len() {
1181                    continue;
1182                }
1183
1184                if total_frame_len == data_len {
1185                    let t_max = parity / 2;
1186                    let stats = ecc::RsDecodeStats {
1187                        total_errors: first_errors,
1188                        error_capacity: t_max,
1189                        max_block_errors: first_errors,
1190                        num_blocks: 1,
1191                    };
1192                    return Some((first_block_data, stats));
1193                }
1194
1195                if total_frame_len < data_len {
1196                    let t_max = parity / 2;
1197                    let stats = ecc::RsDecodeStats {
1198                        total_errors: first_errors,
1199                        error_capacity: t_max,
1200                        max_block_errors: first_errors,
1201                        num_blocks: 1,
1202                    };
1203                    return Some((first_block_data[..total_frame_len].to_vec(), stats));
1204                }
1205
1206                if total_frame_len > data_len
1207                    && let Ok((decoded, stats)) = ecc::rs_decode_blocks_with_parity(
1208                        &extracted_bytes[..rs_encoded_len],
1209                        total_frame_len,
1210                        parity,
1211                    ) {
1212                        return Some((decoded, stats));
1213                    }
1214                    // Multi-block decode failed — first-block was a false
1215                    // positive. Continue sweep to try other data_len values.
1216            }
1217    }
1218
1219    None
1220}
1221
1222/// Try to RS-decode a frame from extracted bytes (Phase 1 path, fixed parity=64).
1223fn try_rs_decode_frame(extracted_bytes: &[u8]) -> Result<(Vec<u8>, ecc::RsDecodeStats), StegoError> {
1224    let parity = ecc::parity_len();
1225
1226    let min_data = crate::stego::frame::FRAME_OVERHEAD;
1227    let max_first_block_data = 191usize; // K_DEFAULT
1228
1229    for data_len in min_data..=max_first_block_data.min(extracted_bytes.len().saturating_sub(parity))
1230    {
1231        let block_len = data_len + parity;
1232        if block_len > extracted_bytes.len() {
1233            break;
1234        }
1235
1236        if let Ok((first_block_data, first_errors)) = ecc::rs_decode(&extracted_bytes[..block_len], data_len)
1237            && first_block_data.len() >= 2 {
1238                let pt_len =
1239                    u16::from_be_bytes([first_block_data[0], first_block_data[1]]) as usize;
1240
1241                // Plausibility: plaintext_len must be positive.
1242                if pt_len == 0 {
1243                    continue;
1244                }
1245
1246                let ct_len = pt_len + 16;
1247                let total_frame_len = 2 + 16 + 12 + ct_len + 4;
1248
1249                if total_frame_len > frame::MAX_FRAME_BYTES {
1250                    continue;
1251                }
1252
1253                // Plausibility: the RS-encoded frame must fit in extracted data.
1254                let rs_encoded_len = ecc::rs_encoded_len(total_frame_len);
1255                if rs_encoded_len > extracted_bytes.len() {
1256                    continue;
1257                }
1258
1259                if total_frame_len <= data_len {
1260                    let stats = ecc::RsDecodeStats {
1261                        total_errors: first_errors,
1262                        error_capacity: ecc::T_MAX,
1263                        max_block_errors: first_errors,
1264                        num_blocks: 1,
1265                    };
1266                    // Single block: truncate to exact frame length if needed.
1267                    let frame_data = if total_frame_len == data_len {
1268                        first_block_data
1269                    } else {
1270                        first_block_data[..total_frame_len].to_vec()
1271                    };
1272                    return Ok((frame_data, stats));
1273                }
1274
1275                // total_frame_len > data_len: multi-block RS decode
1276                if let Ok((decoded, stats)) = ecc::rs_decode_blocks(
1277                    &extracted_bytes[..rs_encoded_len],
1278                    total_frame_len,
1279                ) {
1280                    return Ok((decoded, stats));
1281                }
1282                // Multi-block decode failed — first-block was a false
1283                // positive. Continue sweep to try other data_len values.
1284            }
1285    }
1286
1287    Err(StegoError::FrameCorrupted)
1288}
1289
1290// --- DctGrid flat access helpers ---
1291
1292/// Read a coefficient from a `DctGrid` using a flat index.
1293fn flat_get(grid: &DctGrid, flat_idx: usize) -> i16 {
1294    let bw = grid.blocks_wide();
1295    let block_idx = flat_idx / 64;
1296    let pos = flat_idx % 64;
1297    let br = block_idx / bw;
1298    let bc = block_idx % bw;
1299    let i = pos / 8;
1300    let j = pos % 8;
1301    grid.get(br, bc, i, j)
1302}
1303
1304/// Write a coefficient into a `DctGrid` using a flat index.
1305fn flat_set(grid: &mut DctGrid, flat_idx: usize, val: i16) {
1306    let bw = grid.blocks_wide();
1307    let block_idx = flat_idx / 64;
1308    let pos = flat_idx % 64;
1309    let br = block_idx / bw;
1310    let bc = block_idx % bw;
1311    let i = pos / 8;
1312    let j = pos % 8;
1313    grid.set(br, bc, i, j, val);
1314}
1315
1316/// Dual-tier capacity information for Armor mode.
1317///
1318/// Reports both Fortress (BA-QIM) and STDM capacities so the UI can show
1319/// which sub-mode will be used for the current message length.
1320#[derive(Debug, Clone)]
1321pub struct ArmorCapacityInfo {
1322    /// Maximum plaintext bytes embeddable via Fortress (BA-QIM). 0 if image too small.
1323    pub fortress_capacity: usize,
1324    /// Maximum plaintext bytes embeddable via STDM (standard Armor).
1325    pub stdm_capacity: usize,
1326}
1327
1328/// Compute dual-tier capacity information for an Armor-mode image.
1329///
1330/// Returns both Fortress and STDM capacities. The existing `armor_capacity()`
1331/// function continues to return `stdm_capacity` for backward compatibility.
1332pub fn armor_capacity_info(jpeg_bytes: &[u8]) -> Result<ArmorCapacityInfo, StegoError> {
1333    let img = JpegImage::from_bytes(jpeg_bytes)?;
1334
1335    if img.num_components() == 0 {
1336        return Err(StegoError::NoLuminanceChannel);
1337    }
1338
1339    let fortress_cap = fortress::fortress_capacity(&img).unwrap_or(0);
1340    let stdm_cap = super::capacity::estimate_armor_capacity(&img).unwrap_or(0);
1341
1342    Ok(ArmorCapacityInfo {
1343        fortress_capacity: fortress_cap,
1344        stdm_capacity: stdm_cap,
1345    })
1346}
1347
1348/// Embed a DFT template and ring payload into the luminance channel.
1349///
1350/// Embeds both template peaks (for geometry resilience) and the DFT ring
1351/// payload (resize-robust second layer) in the same FFT pass.
1352fn embed_dft_template(img: &mut JpegImage, passphrase: &str, message: &str) -> Result<(), StegoError> {
1353    let (luma_pixels, w, h) = pixels::jpeg_to_luma_f64(img)
1354        .ok_or(StegoError::NoLuminanceChannel)?;
1355
1356    // P0: Drop luma_pixels after FFT — only needed to produce the spectrum.
1357    let mut spectrum = fft2d::fft2d(&luma_pixels, w, h);
1358    drop(luma_pixels);
1359
1360    // Template peaks for geometry estimation
1361    let peaks = template::generate_template_peaks(passphrase, w, h)?;
1362    template::embed_template(&mut spectrum, &peaks);
1363
1364    // Ring payload -- truncate message to ring capacity
1365    use crate::stego::armor::dft_payload;
1366    let ring_cap = dft_payload::ring_capacity(w, h);
1367    if ring_cap > 0 && !message.is_empty() {
1368        // Truncate at a valid UTF-8 character boundary to avoid splitting
1369        // multi-byte characters (e.g., emoji, CJK, accented chars).
1370        let max_byte = message.len().min(ring_cap);
1371        let truncated_len = message[..max_byte]
1372            .char_indices()
1373            .last()
1374            .map_or(0, |(i, c)| i + c.len_utf8());
1375        let truncated = &message.as_bytes()[..truncated_len];
1376        dft_payload::embed_ring_payload(&mut spectrum, truncated, passphrase)?;
1377    }
1378
1379    // P0: Drop spectrum after IFFT — only needed to produce the modified pixels.
1380    let modified = fft2d::ifft2d(&spectrum);
1381    drop(spectrum);
1382
1383    pixels::luma_f64_to_jpeg(&modified, w, h, img)
1384        .ok_or(StegoError::NoLuminanceChannel)?;
1385    Ok(())
1386}
1387
1388// --- Fortress QF75 pre-settlement ---
1389
1390/// Standard JPEG luminance quantization table (Table K.1 from the JPEG spec).
1391/// These are the base values before quality-factor scaling.
1392/// NOTE: Duplicates STD_LUMA_QT in selection.rs — kept here to avoid coupling
1393/// the Fortress pre-settlement path to the stability-map module.
1394const JPEG_BASE_LUMINANCE_QT: [u16; 64] = [
1395    16, 11, 10, 16,  24,  40,  51,  61,
1396    12, 12, 14, 19,  26,  58,  60,  55,
1397    14, 13, 16, 24,  40,  57,  69,  56,
1398    14, 17, 22, 29,  51,  87,  80,  62,
1399    18, 22, 37, 56,  68, 109, 103,  77,
1400    24, 35, 55, 64,  81, 104, 113,  92,
1401    49, 64, 78, 87, 103, 121, 120, 101,
1402    72, 92, 95, 98, 112, 100, 103,  99,
1403];
1404
1405/// Standard JPEG chrominance quantization table (Table K.2 from the JPEG spec).
1406/// NOTE: Duplicates STD_CHROMA_QT in selection.rs — kept here for the same reason.
1407const JPEG_BASE_CHROMINANCE_QT: [u16; 64] = [
1408    17, 18, 24, 47, 99, 99, 99, 99,
1409    18, 21, 26, 66, 99, 99, 99, 99,
1410    24, 26, 56, 99, 99, 99, 99, 99,
1411    47, 66, 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    99, 99, 99, 99, 99, 99, 99, 99,
1416];
1417
1418/// Compute a JPEG quantization table for a given quality factor (1-100).
1419///
1420/// Uses the standard libjpeg scaling formula:
1421/// - QF >= 50: scale = 200 - 2 * QF
1422/// - QF <  50: scale = 5000 / QF
1423///
1424/// NOTE: Duplicates scale_quant_table() in selection.rs — kept here to avoid
1425/// coupling the Fortress pre-settlement path to the stability-map module.
1426fn compute_jpeg_qt(base: &[u16; 64], qf: u32) -> [u16; 64] {
1427    let scale = if qf >= 50 { 200 - 2 * qf } else { 5000 / qf };
1428    let mut qt = [0u16; 64];
1429    for i in 0..64 {
1430        let val = (base[i] as u32 * scale + 50) / 100;
1431        qt[i] = val.clamp(1, 255) as u16;
1432    }
1433    qt
1434}
1435
1436/// Pre-settle all image components to QF75 quantization tables.
1437///
1438/// For each component, performs IDCT → pixel clamp → DCT with QF75 QT tables,
1439/// then replaces the component's quantization table. This makes the coefficients
1440/// (especially Y-channel DCs used by Fortress) nearly idempotent under Q75
1441/// recompression, which is what WhatsApp and most social media platforms use.
1442///
1443/// Without this, platform-side quality settings vary wildly (iOS 0.65 ≈ Q90,
1444/// Android Q70, Web 0.65 ≈ varies), leaving coefficients on a fine grid that
1445/// shifts dramatically when recompressed to Q75.
1446fn pre_settle_for_fortress(img: &mut JpegImage) -> Result<(), StegoError> {
1447    use crate::codec::jpeg::dct::QuantTable;
1448
1449    let num_components = img.num_components();
1450    let target_qf = 75u32;
1451
1452    // Pre-compute target QT for each QT slot (luminance vs chrominance base).
1453    // We must re-quantize ALL components, even those sharing a QT ID,
1454    // because each component has its own DctGrid with separate coefficients.
1455    let mut new_qts: Vec<(usize, [u16; 64], [u16; 64])> = Vec::new(); // (qt_id, old, new)
1456
1457    for comp_idx in 0..num_components {
1458        let qt_id = img.frame_info().components[comp_idx].quant_table_id as usize;
1459        let old_qt = img.quant_table(qt_id)
1460            .ok_or(StegoError::NoLuminanceChannel)?.values;
1461        let base = if comp_idx == 0 {
1462            &JPEG_BASE_LUMINANCE_QT
1463        } else {
1464            &JPEG_BASE_CHROMINANCE_QT
1465        };
1466        let new_qt = compute_jpeg_qt(base, target_qf);
1467
1468        // Re-quantize all blocks in this component — parallel when available.
1469        let grid = img.dct_grid_mut(comp_idx);
1470
1471        let process_block = |chunk: &mut [i16]| {
1472            let quantized: [i16; 64] = chunk.try_into().unwrap();
1473            let mut px = pixels::idct_block(&quantized, &old_qt);
1474            for p in px.iter_mut() {
1475                *p = p.clamp(0.0, 255.0);
1476            }
1477            let settled = pixels::dct_block(&px, &new_qt);
1478            chunk.copy_from_slice(&settled);
1479        };
1480
1481        #[cfg(feature = "parallel")]
1482        grid.coeffs_mut().par_chunks_mut(64).for_each(process_block);
1483        #[cfg(not(feature = "parallel"))]
1484        grid.coeffs_mut().chunks_mut(64).for_each(process_block);
1485
1486        new_qts.push((qt_id, old_qt, new_qt));
1487    }
1488
1489    // Replace quantization tables (deduplicate by QT ID)
1490    let mut replaced = [false; 4];
1491    for (qt_id, _old, new_qt) in &new_qts {
1492        if !replaced[*qt_id] {
1493            img.set_quant_table(*qt_id, QuantTable::new(*new_qt));
1494            replaced[*qt_id] = true;
1495        }
1496    }
1497    Ok(())
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    use super::*;
1503
1504    #[test]
1505    fn compute_integrity_pristine() {
1506        // Pristine: signal_strength == reference, 0 RS errors
1507        let stats = ecc::RsDecodeStats {
1508            total_errors: 0,
1509            error_capacity: 32,
1510            max_block_errors: 0,
1511            num_blocks: 1,
1512        };
1513        let integrity = compute_integrity(15.0, &stats, 15.0);
1514        // 0.7 * 1.0 + 0.3 * 1.0 = 1.0 → 100
1515        assert_eq!(integrity, 100);
1516    }
1517
1518    #[test]
1519    fn compute_integrity_half_signal() {
1520        // Signal is half the reference, no RS errors
1521        let stats = ecc::RsDecodeStats {
1522            total_errors: 0,
1523            error_capacity: 32,
1524            max_block_errors: 0,
1525            num_blocks: 1,
1526        };
1527        let integrity = compute_integrity(7.5, &stats, 15.0);
1528        // 0.7 * 0.5 + 0.3 * 1.0 = 0.65 → 65
1529        assert_eq!(integrity, 65);
1530    }
1531
1532    #[test]
1533    fn compute_integrity_zero_signal() {
1534        // No signal, no RS errors
1535        let stats = ecc::RsDecodeStats {
1536            total_errors: 0,
1537            error_capacity: 32,
1538            max_block_errors: 0,
1539            num_blocks: 1,
1540        };
1541        let integrity = compute_integrity(0.0, &stats, 15.0);
1542        // 0.7 * 0.0 + 0.3 * 1.0 = 0.3 → 30
1543        assert_eq!(integrity, 30);
1544    }
1545
1546    #[test]
1547    fn compute_integrity_with_rs_errors() {
1548        // Full signal, half RS capacity used
1549        let stats = ecc::RsDecodeStats {
1550            total_errors: 16,
1551            error_capacity: 32,
1552            max_block_errors: 16,
1553            num_blocks: 1,
1554        };
1555        let integrity = compute_integrity(15.0, &stats, 15.0);
1556        // 0.7 * 1.0 + 0.3 * 0.5 = 0.85 → 85
1557        assert_eq!(integrity, 85);
1558    }
1559
1560    #[test]
1561    fn compute_integrity_both_degraded() {
1562        // Half signal, half RS capacity used
1563        let stats = ecc::RsDecodeStats {
1564            total_errors: 16,
1565            error_capacity: 32,
1566            max_block_errors: 16,
1567            num_blocks: 1,
1568        };
1569        let integrity = compute_integrity(7.5, &stats, 15.0);
1570        // 0.7 * 0.5 + 0.3 * 0.5 = 0.5 → 50
1571        assert_eq!(integrity, 50);
1572    }
1573
1574    #[test]
1575    fn compute_integrity_signal_exceeds_reference() {
1576        // Signal > reference (clamped to 1.0)
1577        let stats = ecc::RsDecodeStats {
1578            total_errors: 0,
1579            error_capacity: 32,
1580            max_block_errors: 0,
1581            num_blocks: 1,
1582        };
1583        let integrity = compute_integrity(20.0, &stats, 15.0);
1584        // 0.7 * 1.0 + 0.3 * 1.0 = 1.0 → 100 (clamped)
1585        assert_eq!(integrity, 100);
1586    }
1587
1588    #[test]
1589    fn compute_integrity_zero_reference() {
1590        // Edge case: reference_llr = 0 → llr_score defaults to 1.0
1591        let stats = ecc::RsDecodeStats {
1592            total_errors: 0,
1593            error_capacity: 0,
1594            max_block_errors: 0,
1595            num_blocks: 0,
1596        };
1597        let integrity = compute_integrity(5.0, &stats, 0.0);
1598        // llr_score = 1.0 (fallback), rs_score = 1.0 (error_capacity == 0)
1599        assert_eq!(integrity, 100);
1600    }
1601
1602    #[test]
1603    fn compute_avg_abs_llr_basic() {
1604        let llrs = vec![5.0, -3.0, 4.0, -2.0];
1605        let avg = compute_avg_abs_llr(&llrs);
1606        // (5 + 3 + 4 + 2) / 4 = 3.5
1607        assert!((avg - 3.5).abs() < 1e-10);
1608    }
1609
1610    #[test]
1611    fn compute_avg_abs_llr_empty() {
1612        assert_eq!(compute_avg_abs_llr(&[]), 0.0);
1613    }
1614
1615    #[test]
1616    fn decode_quality_ghost_unchanged() {
1617        let q = DecodeQuality::ghost();
1618        assert_eq!(q.integrity_percent, 100, "Ghost always 100%");
1619        assert_eq!(q.signal_strength, 0.0);
1620    }
1621
1622    #[test]
1623    fn decode_quality_from_rs_stats_with_signal_pristine() {
1624        let stats = ecc::RsDecodeStats {
1625            total_errors: 0,
1626            error_capacity: 32,
1627            max_block_errors: 0,
1628            num_blocks: 1,
1629        };
1630        let q = DecodeQuality::from_rs_stats_with_signal(&stats, 5, 64, 15.0, 15.0);
1631        assert_eq!(q.integrity_percent, 100);
1632        assert!((q.signal_strength - 15.0).abs() < 1e-10);
1633        assert_eq!(q.repetition_factor, 5);
1634        assert_eq!(q.parity_len, 64);
1635    }
1636
1637}