// Copyright (c) 2026 Christoph Gaffga
// SPDX-License-Identifier: GPL-3.0-only
// https://github.com/cgaffga/phasmcore

//! Ghost mode encode/decode pipeline.
//!
//! Ghost mode embeds encrypted messages into JPEG DCT coefficients using:
//! 1. J-UNIWARD cost function to identify low-detectability embedding positions
//! 2. Fisher-Yates permutation (keyed by passphrase) to scatter the payload
//! 3. Syndrome-Trellis Coding (STC) to minimize total embedding distortion
//! 4. nsF5-style LSB modification (toward zero for |coeff| > 1, away from
//!    zero for |coeff| == 1 to prevent shrinkage)

use crate::codec::jpeg::JpegImage;
use crate::stego::cost::uniward::{
    apply_dct_modifications_to_wavelets, compute_cover_wavelets,
    compute_positions_dual_from_wavelets, compute_positions_streaming,
    compute_positions_with_dirty_recost,
};
use crate::stego::crypto;
use crate::stego::error::StegoError;
use crate::stego::frame::{self, MAX_FRAME_BITS};
use crate::stego::payload::{self, FileEntry, PayloadData};
use crate::stego::permute::{self, CoeffPos};
use crate::stego::progress;
use crate::stego::side_info::{self, SideInfo};
use crate::stego::shadow;
use crate::stego::quality::{self, EncodeQuality, GhostMetrics};
use crate::stego::stc::{embed, extract, hhat};
use crate::stego::memory::{self, GhostShadowRung, ModeId, TelemetryEvent};

/// STC constraint length for Ghost Phase 1.
const STC_H: usize = 7;

/// Maximum number of STC cover positions before OOM protection kicks in.
/// 500M positions × 16 bytes/u128 = 8 GB back pointer memory (segmented
/// path uses O(sqrt(n)) but the coefficient vectors are still O(n)).
const STC_POSITION_LIMIT: usize = 500_000_000;

/// Progress steps allocated to JPEG parsing.  Reported immediately after
/// `JpegImage::from_bytes` returns so the bar moves off 0% right away.
const PARSE_STEPS: u32 = 5;

/// Total number of progress steps reported by [`ghost_decode`].
///
/// 5 (parse) + 100 (UNIWARD) + 2 (STC extraction + decryption).
pub const GHOST_DECODE_STEPS: u32 = PARSE_STEPS + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS + 2;

/// Total number of progress steps reported by Ghost encode.
///
/// 5 (parse) + 100 (UNIWARD sub-steps) + 50 (STC Viterbi sub-steps) + 2 (permute + LSB mod) + 20 (JPEG write).
pub const GHOST_ENCODE_STEPS: u32 =
    PARSE_STEPS
    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS
    + crate::stego::stc::embed::STC_PROGRESS_STEPS
    + 2
    + crate::codec::jpeg::scan::JPEG_WRITE_STEPS;

/// Compute the STC width `w`, usable cover length, and effective `m_max` from
/// the total number of AC positions. Both encoder and decoder must agree on
/// these values — they can because both see the same image and derive the
/// same `n`.
///
/// `m_max` is capped at `MAX_FRAME_BITS` (the u16 protocol limit) but also
/// capped at `n` so that small images still work (with `w = 1`).
///
/// Uses `w = floor(n / m_max)` so that `n_used = m_max * w <= n` and the
/// extraction produces exactly `m_max` bits.
///
/// # Returns
/// `(w, n_used, m_max)` where `w` is the STC submatrix width, `n_used` is the
/// number of cover positions to use (always <= `n`), and `m_max` is the
/// effective extraction size in bits.
///
/// # Errors
/// Returns [`StegoError::ImageTooSmall`] if `n` is 0.
/// Returns [`StegoError::ImageTooLarge`] if the STC Viterbi back_ptr would
/// exceed the memory budget (prevents OOM on very large images).
fn compute_stc_params(n: usize) -> Result<(usize, usize, usize), StegoError> {
    let m_max = MAX_FRAME_BITS.min(n);
    if m_max == 0 {
        return Err(StegoError::ImageTooSmall);
    }
    let w = n / m_max; // floor division
    let n_used = m_max * w;

    if n_used > STC_POSITION_LIMIT {
        return Err(StegoError::ImageTooLarge);
    }

    Ok((w, n_used, m_max))
}

/// Encode a text message into a cover JPEG using Ghost mode.
///
/// # Arguments
/// - `image_bytes`: Raw bytes of the cover JPEG image.
/// - `message`: The plaintext message to embed (must fit within capacity).
/// - `passphrase`: Used for both structural key derivation and encryption.
///
/// # Returns
/// The stego JPEG image as bytes, or an error if the image is too small
/// or the message exceeds the embedding capacity.
///
/// # Errors
/// - [`StegoError::InvalidJpeg`] if `image_bytes` is not a valid baseline JPEG.
/// - [`StegoError::NoLuminanceChannel`] if the image has no Y component.
/// - [`StegoError::ImageTooSmall`] if the cover has too few usable coefficients.
/// - [`StegoError::MessageTooLarge`] if the message exceeds STC capacity.
pub fn ghost_encode(
    image_bytes: &[u8],
    message: &str,
    passphrase: &str,
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_impl(image_bytes, message, &[], passphrase, None, None)
        .map(|(bytes, _)| bytes)
}

/// Encode with Ghost mode and return the encode quality score.
pub fn ghost_encode_with_quality(
    image_bytes: &[u8],
    message: &str,
    passphrase: &str,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    ghost_encode_impl(image_bytes, message, &[], passphrase, None, None)
}

/// Encode a text message with file attachments into a cover JPEG using Ghost mode.
///
/// Files are embedded alongside the text message in the payload. The entire
/// payload (text + files) is compressed with Brotli before encryption.
pub fn ghost_encode_with_files(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_impl(image_bytes, message, files, passphrase, None, None)
        .map(|(bytes, _)| bytes)
}

/// Encode with Ghost mode + files and return the encode quality score.
pub fn ghost_encode_with_files_quality(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    ghost_encode_impl(image_bytes, message, files, passphrase, None, None)
}

/// Encode using Ghost mode with side information (SI-UNIWARD / "Deep Cover").
///
/// When the original uncompressed pixels are available (non-JPEG input like
/// PNG, HEIC, or RAW), the quantization rounding errors enable more efficient
/// embedding — roughly 1.5-2× capacity at the same detection risk.
///
/// The `raw_pixels_rgb` must be the ORIGINAL pixels that were JPEG-compressed
/// to produce `image_bytes`. They must have the same dimensions.
///
/// # Arguments
/// - `image_bytes`: Cover JPEG (as compressed by the platform from the raw pixels).
/// - `raw_pixels_rgb`: Original RGB pixels, row-major, 3 bytes per pixel.
/// - `pixel_width`, `pixel_height`: Dimensions of the raw pixel buffer.
/// - `message`: Plaintext message to embed.
/// - `passphrase`: Used for structural key derivation and encryption.
pub fn ghost_encode_si(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    passphrase: &str,
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_si_with_files(
        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
        message, &[], passphrase,
    )
}

/// Encode with SI-UNIWARD and return the encode quality score.
pub fn ghost_encode_si_with_quality(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    passphrase: &str,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    ghost_encode_si_with_files_quality(
        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
        message, &[], passphrase,
    )
}

/// Encode with side information and file attachments.
pub fn ghost_encode_si_with_files(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_si_with_files_quality(
        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
        message, files, passphrase,
    ).map(|(bytes, _)| bytes)
}

/// Encode with SI-UNIWARD + files and return the encode quality score.
pub fn ghost_encode_si_with_files_quality(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    // Parse image first to get the grid and QT for side info computation
    let img = JpegImage::from_bytes(image_bytes)?;
    let fi = img.frame_info();
    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;

    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    let qt_id = fi.components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;

    let si = SideInfo::compute(
        raw_pixels_rgb,
        pixel_width,
        pixel_height,
        img.dct_grid(0),
        &qt.values,
    )?;

    // Pass pre-parsed image through to avoid re-parsing in ghost_encode_impl
    ghost_encode_impl(image_bytes, message, files, passphrase, Some(si), Some(img))
}

/// Shadow layer descriptor for encoding.
pub struct ShadowLayer {
    /// Text message for this shadow layer.
    pub message: String,
    /// Passphrase for this shadow layer (must be unique across all layers).
    pub passphrase: String,
    /// Optional file attachments for this shadow layer.
    pub files: Vec<FileEntry>,
}

/// Progress steps for Ghost encode with shadows.
///
/// 5 (parse) + 100 (UNIWARD) + 1 (shadow prepare) + 1 (permute)
/// + 50 (STC) + 100 (verification UNIWARD) + 20 (JPEG write).
/// If the escalation cascade triggers, the total is bumped dynamically
/// via `progress::set_total()` to avoid the bar stalling at 100%.
pub const GHOST_ENCODE_WITH_SHADOWS_STEPS: u32 =
    PARSE_STEPS
    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS
    + 2  // shadow prep + permute
    + crate::stego::stc::embed::STC_PROGRESS_STEPS
    + crate::stego::cost::uniward::UNIWARD_PROGRESS_STEPS  // verification pass
    + crate::codec::jpeg::scan::JPEG_WRITE_STEPS;

/// Escalation cascade for shadow verification.
///
/// Tries fraction=1 (100% pool) first — eliminates boundary disagreement
/// entirely. If fraction=1 fails, tighter fractions won't help at the same
/// parity. Then tries tighter fractions with higher proven parity for stealth.
const CASCADE: &[(usize, usize)] = &[
    // Try 100% pool first — eliminates boundary disagreement entirely.
    // If this fails, tighter fractions won't help.
    (1, 4),
    (1, 8),
    (1, 16),
    (1, 32),
    (1, 64),
    (1, 128),
    // Then try tighter fractions with proven-working parity for better stealth.
    (2, 16), (5, 16), (10, 16),
    (2, 32), (5, 32),
    (2, 64),
];

/// Encode with Ghost mode plus shadow messages for plausible deniability.
///
/// Shadow layers are embedded first using repetition coding. The primary
/// message is then embedded via STC, treating shadow modifications as
/// part of the cover. Each passphrase must be unique.
pub fn ghost_encode_with_shadows(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
    si: Option<SideInfo>,
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, si, None)
        .map(|(bytes, _)| bytes)
}

/// Encode with Ghost mode + shadows and return the encode quality score.
pub fn ghost_encode_with_shadows_quality(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
    si: Option<SideInfo>,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, si, None)
}

/// Encode with Ghost SI-UNIWARD plus shadow messages.
pub fn ghost_encode_si_with_shadows(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
) -> Result<Vec<u8>, StegoError> {
    ghost_encode_si_with_shadows_quality(
        image_bytes, raw_pixels_rgb, pixel_width, pixel_height,
        message, files, passphrase, shadows,
    ).map(|(bytes, _)| bytes)
}

/// Encode with SI-UNIWARD + shadows and return the encode quality score.
pub fn ghost_encode_si_with_shadows_quality(
    image_bytes: &[u8],
    raw_pixels_rgb: &[u8],
    pixel_width: u32,
    pixel_height: u32,
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    let img = JpegImage::from_bytes(image_bytes)?;
    let fi = img.frame_info();
    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;

    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    let qt_id = fi.components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;

    let si = SideInfo::compute(
        raw_pixels_rgb,
        pixel_width,
        pixel_height,
        img.dct_grid(0),
        &qt.values,
    )?;

    ghost_encode_with_shadows_impl(image_bytes, message, files, passphrase, shadows, Some(si), Some(img))
}

fn ghost_encode_with_shadows_impl(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
    si: Option<SideInfo>,
    pre_parsed: Option<JpegImage>,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    // M4: parse the JPEG up front so the ladder selector knows
    // dimensions BEFORE the dominant allocations
    // (cover_wavelets / positions / original_y) happen.
    let parsed_img = match pre_parsed {
        Some(img) => img,
        None => JpegImage::from_bytes(image_bytes)?,
    };
    let fi = parsed_img.frame_info();
    let (peek_w, peek_h) = (fi.width as u32, fi.height as u32);
    let mode_id = if si.is_some() {
        ModeId::GhostSiShadow
    } else {
        ModeId::GhostShadow
    };
    let n_workers = workers_for_rung();
    let rung = memory::select_ghost_shadow_rung(mode_id, peek_w, peek_h, n_workers);
    let predicted_mb = (memory::predict_peak_memory(mode_id, peek_w, peek_h, rung, n_workers)
        / (1024 * 1024)) as u32;
    let budget_mb = memory::get_memory_budget().map(|b| (b / (1024 * 1024)) as u32);
    memory::emit(TelemetryEvent::LadderRungSelected {
        mode: mode_id,
        width: peek_w,
        height: peek_h,
        predicted_peak_mb: predicted_mb,
        budget_mb,
        rung: rung_to_u8(rung),
    });
    memory::emit(TelemetryEvent::EncodeStarted {
        mode: mode_id,
        width: peek_w,
        height: peek_h,
        predicted_peak_mb: predicted_mb,
        budget_mb,
        ladder_rung: rung_to_u8(rung),
    });
    let encode_start = std::time::Instant::now();
    let outcome = ghost_encode_with_shadows_impl_rung(
        image_bytes, message, files, passphrase, shadows, si, Some(parsed_img), rung,
    );
    let wall_ms = encode_start.elapsed().as_millis().min(u32::MAX as u128) as u32;
    match &outcome {
        Ok(_) => memory::emit(TelemetryEvent::EncodeCompleted {
            mode: mode_id,
            width: peek_w,
            height: peek_h,
            ladder_rung: rung_to_u8(rung),
            wall_clock_ms: wall_ms,
        }),
        Err(StegoError::Cancelled) => memory::emit(TelemetryEvent::EncodeCancelled {
            mode: mode_id,
            width: peek_w,
            height: peek_h,
            ladder_rung: rung_to_u8(rung),
            wall_clock_ms_when_cancelled: wall_ms,
        }),
        Err(e) => memory::emit(TelemetryEvent::EncodeFailed {
            mode: mode_id,
            width: peek_w,
            height: peek_h,
            reason: stego_error_kind(e),
        }),
    }
    outcome
}

fn rung_to_u8(r: GhostShadowRung) -> u8 {
    match r {
        GhostShadowRung::FullParallel => 0,
        GhostShadowRung::CappedParallel => 1,
        GhostShadowRung::StreamingNoCache => 2,
        GhostShadowRung::MinimalClones => 3,
    }
}

fn workers_for_rung() -> usize {
    #[cfg(feature = "parallel")]
    {
        std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(1)
    }
    #[cfg(not(feature = "parallel"))]
    {
        1
    }
}

fn stego_error_kind(e: &StegoError) -> &'static str {
    match e {
        StegoError::InvalidJpeg(_) => "InvalidJpeg",
        StegoError::ImageTooSmall => "ImageTooSmall",
        StegoError::ImageTooLarge => "ImageTooLarge",
        StegoError::MessageTooLarge => "MessageTooLarge",
        StegoError::FrameCorrupted => "FrameCorrupted",
        StegoError::DecryptionFailed => "DecryptionFailed",
        StegoError::InvalidUtf8 => "InvalidUtf8",
        StegoError::NoLuminanceChannel => "NoLuminanceChannel",
        StegoError::Cancelled => "Cancelled",
        StegoError::KeyDerivationFailed => "KeyDerivationFailed",
        StegoError::DuplicatePassphrase => "DuplicatePassphrase",
        StegoError::ShadowEmbedFailed => "ShadowEmbedFailed",
        StegoError::InvalidVideo(_) => "InvalidVideo",
    }
}

fn ghost_encode_with_shadows_impl_rung(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
    si: Option<SideInfo>,
    pre_parsed: Option<JpegImage>,
    rung: GhostShadowRung,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    // M4a (rung 0/1): The cascade body uses par_iter, which routes through
    // the current rayon thread context. For rung >= CappedParallel we run
    // the entire body inside a single-thread pool so every par_iter
    // automatically respects the cap. Rung 0 keeps the current global pool.
    #[cfg(feature = "parallel")]
    if matches!(rung,
        GhostShadowRung::CappedParallel
        | GhostShadowRung::StreamingNoCache
        | GhostShadowRung::MinimalClones,
    ) {
        // Single-threaded pool failure only happens on extreme OOM
        // (we couldn't allocate one thread). Treat as MessageTooLarge
        // since the caller's correct response is the same: try a
        // smaller image.
        let pool = rayon::ThreadPoolBuilder::new()
            .num_threads(1)
            .build()
            .map_err(|_| StegoError::MessageTooLarge)?;
        return pool.install(move || {
            ghost_encode_with_shadows_impl_inner(
                image_bytes, message, files, passphrase, shadows, si, pre_parsed, rung,
            )
        });
    }
    ghost_encode_with_shadows_impl_inner(
        image_bytes, message, files, passphrase, shadows, si, pre_parsed, rung,
    )
}

fn ghost_encode_with_shadows_impl_inner(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    shadows: &[ShadowLayer],
    si: Option<SideInfo>,
    pre_parsed: Option<JpegImage>,
    _rung: GhostShadowRung,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    // Initialize progress with worst-case cascade budget so the bar moves
    // smoothly without dynamic set_total() jumps.
    //
    // T3 recalibration: cascade verify replaced the full UNIWARD
    // recompute with the dirty re-cost path (T3.2). Per-cascade-iter
    // wall-clock dropped ~17×, so the per-iter budget drops from
    // UNIWARD_PROGRESS_STEPS=100 to DIRTY_RECOST_PROGRESS_STEPS=10
    // to keep step granularity matched to actual work.
    //
    // Additional one-time upfront budget for the cascade-trigger
    // cover_wavelets + cover_verify_positions (the
    // `compute_positions_from_wavelets` pass on the COVER image,
    // emitted internally as POSITIONS_FROM_WAVELETS_PROGRESS_STEPS
    // sub-steps via the atomic-counter pattern).
    let cascade_budget = CASCADE.len() as u32 * (
        crate::stego::stc::embed::STC_PROGRESS_STEPS
        + crate::stego::cost::uniward::DIRTY_RECOST_PROGRESS_STEPS
    )
    + crate::stego::cost::uniward::COVER_WAVELETS_PROGRESS_STEPS
    + crate::stego::cost::uniward::POSITIONS_FROM_WAVELETS_PROGRESS_STEPS;
    progress::init(GHOST_ENCODE_WITH_SHADOWS_STEPS + cascade_budget);

    // Validate passphrases are unique (primary + all shadows).
    {
        let mut all_passes: Vec<&str> = vec![passphrase];
        for s in shadows {
            all_passes.push(&s.passphrase);
        }
        for i in 0..all_passes.len() {
            for j in (i + 1)..all_passes.len() {
                if all_passes[i] == all_passes[j] {
                    return Err(StegoError::DuplicatePassphrase);
                }
            }
        }
    }

    // Auto-sort: the largest payload becomes primary (gets full STC stealth).
    // Smaller payloads become shadows (direct LSB + RS, negligible detectability).
    let primary_payload_size = payload::compressed_payload_size(message, files);
    let mut swap_idx: Option<usize> = None;
    for (i, s) in shadows.iter().enumerate() {
        let shadow_size = payload::compressed_payload_size(&s.message, &s.files);
        if shadow_size > primary_payload_size {
            if let Some(prev) = swap_idx {
                let prev_size = payload::compressed_payload_size(&shadows[prev].message, &shadows[prev].files);
                if shadow_size > prev_size {
                    swap_idx = Some(i);
                }
            } else {
                swap_idx = Some(i);
            }
        }
    }

    // If a shadow is larger, swap it with primary for optimal stealth.
    // We need `primary_as_shadow` to live long enough, so declare it before the branch.
    let primary_as_shadow;
    let (eff_message, eff_files, eff_passphrase, eff_shadows);
    if let Some(idx) = swap_idx {
        eff_message = shadows[idx].message.as_str();
        eff_files = &shadows[idx].files[..];
        eff_passphrase = shadows[idx].passphrase.as_str();
        // Build new shadows list: original primary + all shadows except the swapped one.
        primary_as_shadow = ShadowLayer {
            message: message.to_string(),
            passphrase: passphrase.to_string(),
            files: files.to_vec(),
        };
        let mut new_shadows: Vec<&ShadowLayer> = Vec::with_capacity(shadows.len());
        new_shadows.push(&primary_as_shadow);
        for (i, s) in shadows.iter().enumerate() {
            if i != idx {
                new_shadows.push(s);
            }
        }
        eff_shadows = new_shadows;
    } else {
        eff_message = message;
        eff_files = files;
        eff_passphrase = passphrase;
        eff_shadows = shadows.iter().collect();
    };

    // Build the primary payload.
    let payload_bytes = payload::encode_payload(eff_message, eff_files)?;

    let mut img = match pre_parsed {
        Some(img) => img,
        None => JpegImage::from_bytes(image_bytes)?,
    };
    progress::advance_by(PARSE_STEPS);
    let fi = img.frame_info();
    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;

    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    // 1. Compute J-UNIWARD positions.
    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
    #[cfg(feature = "parallel")]
    let key_thread = {
        let pass = eff_passphrase.to_string();
        std::thread::spawn(move || crypto::derive_structural_key(&pass))
    };

    let qt_id = img.frame_info().components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
    let si_ref = si.as_ref().map(|s| (s, img.dct_grid(0)));
    // T3.2 — only the SHADOW-with-verify path benefits from caching
    // cover wavelets + a separate no-SI position vec. When no shadows
    // are requested (defensive: `ghost_encode_with_shadows_impl` is
    // sometimes called with an empty shadow list), there's no verify
    // step downstream, so we skip the ~32 MB extra positions clone and
    // the 72 MB cover_wavelets resident allocation.
    //
    // We also can't yet know whether `all_fraction_1` will hold (that
    // depends on prepare_shadow's fraction choice, computed later);
    // for the rare all-fraction-1 case we waste the dual output but
    // not the wall-clock since the dual output is essentially free.
    // M4b: at rung 2+ (StreamingNoCache / MinimalClones) skip the
    // cover_wavelets + cover_verify_positions allocations
    // (~604 MB + ~157 MB at 60 MP). Verify and cascade then use the
    // slower streaming recompute instead of T3.2's dirty re-cost.
    let skip_wavelet_cache = matches!(
        _rung,
        GhostShadowRung::StreamingNoCache | GhostShadowRung::MinimalClones,
    );
    let (cover_wavelets, cover_verify_positions, mut positions) =
        if eff_shadows.is_empty() || skip_wavelet_cache {
            let p = compute_positions_streaming(img.dct_grid(0), qt, si_ref)?;
            (None, None, p)
        } else {
            let cw = compute_cover_wavelets(img.dct_grid(0), qt);
            let (primary, verify) =
                compute_positions_dual_from_wavelets(img.dct_grid(0), qt, &cw, si_ref)?;
            (Some(cw), Some(verify), primary)
        };

    // 2. Prepare shadow layers (Y-channel direct LSB + RS).
    // Sort positions by cost in-place for cost-pool selection (avoids cloning
    // the entire positions vector, saving ~800 MB on 200MP images).
    positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));

    let mut shadow_states: Vec<shadow::ShadowState> = Vec::new();
    if !eff_shadows.is_empty() {
        let initial_parity = 4;
        for s in eff_shadows.iter() {
            let state = shadow::prepare_shadow(
                &positions,
                &s.passphrase,
                &s.message,
                &s.files,
                initial_parity,
            )?;
            shadow_states.push(state);
        }
    }
    progress::advance(); // shadow preparation step

    // Restore raster order (sort by flat_idx) for STC permutation.
    // The raster order matches compute_positions_streaming output.
    // T1.9 — flat_idx is unique per position (grid coordinate), so
    // sort_unstable_by_key produces bit-identical output to sort_by_key
    // (no ties to break stably). ~30% faster on multi-million-element
    // vectors.
    positions.sort_unstable_by_key(|p| p.flat_idx);

    // Save original Y grid for restoration before embedding.
    let original_y = img.dct_grid(0).clone();

    // 3. Primary STC setup.
    #[cfg(feature = "parallel")]
    let structural_key = key_thread.join().expect("key derivation thread")?;
    #[cfg(not(feature = "parallel"))]
    let structural_key = crypto::derive_structural_key(eff_passphrase)?;
    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();

    permute::permute_positions(&mut positions, &perm_seed);
    let n = positions.len();

    // Step: permutation complete.
    progress::advance();

    let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, eff_passphrase)?;
    let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
    let frame_bits = frame::bytes_to_bits(&frame_bytes);
    let m = frame_bits.len();

    // Dynamic w: pick highest that fits the actual primary message.
    let w = (n / m).clamp(1, 10);
    let m_max = n / w;
    let n_used = m_max * w;

    if m > m_max {
        return Err(StegoError::MessageTooLarge);
    }

    // Upfront w check: w=1 means no STC slack — ∞-cost protection disabled,
    // shadow BER ≈ 50%. Fail fast instead of a 10-minute cascade.
    if w < 2 && !shadow_states.is_empty() {
        return Err(StegoError::MessageTooLarge);
    }

    if n_used > STC_POSITION_LIMIT {
        return Err(StegoError::ImageTooLarge);
    }

    positions.truncate(n_used);
    let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);

    // 4. Build ∞-cost mask for shadow positions when w >= 2.
    // When w >= 2, Viterbi has enough slack to route around shadow positions,
    // achieving BER ~ 0% on shadows without any RS correction needed.
    let shadow_inf_costs = build_inf_cost_set(w, &shadow_states);

    // Compute median cost before STC for quality scoring.
    let median_cost = {
        let mut finite_costs: Vec<f32> = positions.iter()
            .map(|p| p.cost)
            .filter(|c| c.is_finite())
            .collect();
        if finite_costs.is_empty() {
            0.0f32
        } else {
            let mid = finite_costs.len() / 2;
            finite_costs.select_nth_unstable_by(mid, f32::total_cmp);
            finite_costs[mid]
        }
    };
    let is_si = si.is_some();
    let grid_ref = img.dct_grid(0);
    let total_coefficients = grid_ref.blocks_wide() * grid_ref.blocks_tall() * 64;

    // Count total shadow embedding positions for quality scoring.
    // Use n_total (actual RS-encoded bit count) rather than positions.len()
    // (the pool size), since only n_total positions are written by embed_shadow_lsb.
    let shadow_modifications: usize = shadow_states.iter()
        .map(|s| s.n_total)
        .sum();

    // 5. Embed shadows + primary STC (always short STC: frame_bits, not padded).
    // Values land via one of the if/else branches below; reassigned in
    // inner search loops too, hence `mut`.
    let mut stc_total_cost: f64;
    let mut stc_num_modifications: usize;

    // Bin where Phase B+ will hold the most recent pass's modified
    // flat positions (for dirty-block UNIWARD re-cost). Unused in
    // Phase A.
    let mut _stc_modified_positions: Vec<u32> = Vec::new();

    if shadow_states.is_empty() {
        // No shadows → no verification UNIWARD pass needed.
        let r = run_stc_pass(&mut img, &original_y, &positions, &[],
                     &frame_bits, &hhat_matrix, w, &si, &None)?;
        stc_total_cost = r.total_cost;
        stc_num_modifications = r.num_modifications;
        _stc_modified_positions = r.modified_positions;
    } else {
        let r = run_stc_pass(&mut img, &original_y, &positions, &shadow_states,
                     &frame_bits, &hhat_matrix, w, &si, &shadow_inf_costs)?;
        stc_total_cost = r.total_cost;
        stc_num_modifications = r.num_modifications;
        _stc_modified_positions = r.modified_positions;

        // Skip verification when ALL shadows use fraction=1 (100% pool).
        // With 100% pool there's no cost-pool boundary, so decoder always
        // selects the same positions regardless of cover vs stego costs.
        // This saves a full UNIWARD recomputation (2-10s on large images).
        let all_fraction_1 = shadow_states.iter().all(|s| s.cost_fraction == 1);

        // Verify shadows from the DECODER's perspective: compute UNIWARD costs
        // on the stego image (as the decoder will see it), select positions
        // using stego costs + same fraction/hash, and check RS decode + decrypt.
        // This catches cost-pool boundary disagreements between cover and stego.
        if !all_fraction_1 {
            let qt_verify = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
            // T3.2: cached cover_wavelets enable dirty re-cost — much
            // faster than the streaming recompute. M4b rung 2+ skips
            // the cache to save ~760 MB at 60 MP and falls back to the
            // streaming path. Both produce the SAME stego positions —
            // dirty re-cost was a wall-clock optimization, not a
            // behavior change. Both the first verify AND every
            // cascade iteration below share the same gating.
            let cover_wavelets = cover_wavelets.as_ref();
            let cover_verify_positions = cover_verify_positions.as_ref();
            let mut stego_y_positions = match (cover_wavelets, cover_verify_positions) {
                (Some(cw), Some(cvp)) => {
                    let stego_wavelets = apply_dct_modifications_to_wavelets(
                        cw, &_stc_modified_positions, &original_y, img.dct_grid(0), qt_verify,
                    );
                    let p = compute_positions_with_dirty_recost(
                        img.dct_grid(0), qt_verify, cvp, &_stc_modified_positions,
                        &stego_wavelets, None,
                    )?;
                    drop(stego_wavelets);
                    p
                }
                _ => {
                    let si_ref_stego = si.as_ref().map(|s| (s, img.dct_grid(0)));
                    compute_positions_streaming(img.dct_grid(0), qt_verify, si_ref_stego)?
                }
            };
            stego_y_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));

            if !verify_all_shadows_decoder_side(&img, &shadow_states, &eff_shadows, &stego_y_positions) {
                // Escalate: try progressively larger fractions (more positions,
                // lower BER) and higher parities (more error correction).
                // CASCADE is ordered: fraction=1 first (eliminates boundary
                // disagreement), then tighter fractions with higher parity.

                // Build cost-sorted positions for cascade (only allocated when
                // verification fails — saves ~800 MB in the happy path on large images).
                let mut cascade_positions = positions.clone();
                cascade_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));

                // cover_wavelets + cover_verify_positions were
                // materialized in the primary positions step — reuse
                // them across every cascade iteration (parallel or
                // serial). No additional upfront cost.

                let mut verified = false;

                #[cfg(feature = "parallel")]
                {
                    use std::sync::atomic::{AtomicUsize, Ordering};
                    use rayon::prelude::*;

                    // Group CASCADE by parity, try all fractions for each parity in parallel.
                    // Within each parity tier, find the BEST (largest) fraction that verifies.
                    // Larger fraction = fewer positions = tighter pool = more stealth.
                    let best_fraction = AtomicUsize::new(0);

                    for &parity in &[4, 8, 16, 32, 64, 128] {
                        let fractions_for_parity: Vec<usize> = CASCADE.iter()
                            .filter(|&&(_, p)| p == parity)
                            .map(|&(f, _)| f)
                            .collect();
                        if fractions_for_parity.is_empty() { continue; }

                        let has_fraction_1 = fractions_for_parity.contains(&1);

                        let successes: Vec<(usize, crate::codec::jpeg::JpegImage, Vec<shadow::ShadowState>, f64, usize)> =
                            fractions_for_parity.par_iter().filter_map(|&fraction| {
                            // Checkpoint: skip if a better (larger) fraction already verified.
                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }

                            // Rebuild shadows with this (fraction, parity).
                            // M4.5 (#669): rebuild_shadow takes &[CoeffPos] — share
                            // the outer cascade_positions by ref instead of per-worker
                            // cloning. Saves N_workers × cascade_positions (~50 MB at
                            // 60 MP × 8 workers = 400 MB peak during cascade).
                            let mut local_states = shadow_states.clone();
                            for state in local_states.iter_mut() {
                                if shadow::rebuild_shadow(state, &cascade_positions, parity, fraction).is_err() {
                                    return None;
                                }
                            }

                            // Checkpoint before STC (expensive).
                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }

                            let mut local_img = img.clone();
                            let new_inf_costs = build_inf_cost_set(w, &local_states);
                            let local_r = match run_stc_pass(&mut local_img, &original_y, &positions, &local_states,
                                         &frame_bits, &hhat_matrix, w, &si, &new_inf_costs) {
                                Ok(v) => v,
                                Err(_) => return None,
                            };
                            // Checkpoint before UNIWARD recomputation (expensive).
                            if best_fraction.load(Ordering::Relaxed) > fraction { return None; }

                            let qt_re = match local_img.quant_table(qt_id) {
                                Some(qt) => qt,
                                None => return None,
                            };
                            // T3.2.E dirty re-cost when the cache is
                            // present (rung 0/1) — per-iter clone of
                            // cover_wavelets mutated by this iter's
                            // modifications. At rung 2+ we use the
                            // slower streaming recompute instead;
                            // same stego positions, ~6× slower per
                            // iter but ~760 MB lighter on the working
                            // set at 60 MP.
                            let mut local_stego_positions = match (cover_wavelets, cover_verify_positions) {
                                (Some(cw), Some(cvp)) => {
                                    let local_stego_wavelets = apply_dct_modifications_to_wavelets(
                                        cw, &local_r.modified_positions, &original_y,
                                        local_img.dct_grid(0), qt_re,
                                    );
                                    let p = match compute_positions_with_dirty_recost(
                                        local_img.dct_grid(0), qt_re, cvp,
                                        &local_r.modified_positions, &local_stego_wavelets, None,
                                    ) {
                                        Ok(p) => p,
                                        Err(_) => return None,
                                    };
                                    drop(local_stego_wavelets);
                                    p
                                }
                                _ => {
                                    let local_si_ref = si.as_ref().map(|s| (s, local_img.dct_grid(0)));
                                    match compute_positions_streaming(
                                        local_img.dct_grid(0), qt_re, local_si_ref,
                                    ) {
                                        Ok(p) => p,
                                        Err(_) => return None,
                                    }
                                }
                            };
                            local_stego_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));

                            if verify_all_shadows_decoder_side(&local_img, &local_states, &eff_shadows, &local_stego_positions) {
                                // Publish: this fraction verified successfully.
                                best_fraction.fetch_max(fraction, Ordering::Relaxed);
                                Some((fraction, local_img, local_states, local_r.total_cost, local_r.num_modifications))
                            } else {
                                None
                            }
                        }).collect();

                        if !successes.is_empty() {
                            // Pick the best (largest fraction = tightest pool = most stealth).
                            let best = successes.into_iter().max_by_key(|(f, _, _, _, _)| *f).unwrap();
                            img = best.1;
                            shadow_states = best.2;
                            stc_total_cost = best.3;
                            stc_num_modifications = best.4;
                            verified = true;
                            break;
                        }

                        // fraction=1 early bailout: if 100% pool fails at this parity,
                        // tighter fractions at higher parity won't fix boundary issues.
                        if has_fraction_1 && parity == 128 {
                            break;
                        }
                    }
                }

                #[cfg(not(feature = "parallel"))]
                {
                    // M6: serial cascade now mirrors the parallel
                    // algorithm — "collect all successes per parity,
                    // pick the LARGEST fraction". Was greedy
                    // first-success in CASCADE order, producing
                    // different stego bytes than parallel for the
                    // same input. Now CLI (no parallel) and mobile
                    // produce byte-identical output.
                    'parity_loop: for &parity in &[4usize, 8, 16, 32, 64, 128] {
                        let fractions_for_parity: Vec<usize> = CASCADE.iter()
                            .filter(|&&(_, p)| p == parity)
                            .map(|&(f, _)| f)
                            .collect();
                        if fractions_for_parity.is_empty() { continue; }
                        let has_fraction_1 = fractions_for_parity.contains(&1);

                        let mut best_fraction: usize = 0;
                        let mut successes: Vec<(usize, JpegImage, Vec<shadow::ShadowState>, f64, usize)> = Vec::new();

                        for &fraction in &fractions_for_parity {
                            // Same work-skip pattern as the parallel
                            // branch: if a larger fraction already
                            // succeeded (this parity or earlier),
                            // smaller ones can't beat it.
                            if best_fraction > fraction { continue; }

                            // M4.5 (#669): share cascade_positions by ref; serial
                            // path needed only 1 clone but the alloc churn × per-
                            // iter is still avoidable.
                            let mut local_states = shadow_states.clone();
                            let mut rebuild_ok = true;
                            for state in local_states.iter_mut() {
                                if shadow::rebuild_shadow(state, &cascade_positions, parity, fraction).is_err() {
                                    rebuild_ok = false;
                                    break;
                                }
                            }
                            if !rebuild_ok { continue; }

                            if best_fraction > fraction { continue; }

                            let mut local_img = img.clone();
                            let new_inf_costs = build_inf_cost_set(w, &local_states);
                            let local_r = match run_stc_pass(
                                &mut local_img, &original_y, &positions, &local_states,
                                &frame_bits, &hhat_matrix, w, &si, &new_inf_costs,
                            ) {
                                Ok(v) => v,
                                Err(_) => continue,
                            };

                            if best_fraction > fraction { continue; }

                            let qt_re = match local_img.quant_table(qt_id) {
                                Some(qt) => qt,
                                None => continue,
                            };
                            let mut local_stego_positions = match (cover_wavelets, cover_verify_positions) {
                                (Some(cw), Some(cvp)) => {
                                    let local_stego_wavelets = apply_dct_modifications_to_wavelets(
                                        cw, &local_r.modified_positions, &original_y,
                                        local_img.dct_grid(0), qt_re,
                                    );
                                    let p = match compute_positions_with_dirty_recost(
                                        local_img.dct_grid(0), qt_re, cvp,
                                        &local_r.modified_positions, &local_stego_wavelets, None,
                                    ) {
                                        Ok(p) => p,
                                        Err(_) => continue,
                                    };
                                    drop(local_stego_wavelets);
                                    p
                                }
                                _ => {
                                    let local_si_ref = si.as_ref().map(|s| (s, local_img.dct_grid(0)));
                                    match compute_positions_streaming(
                                        local_img.dct_grid(0), qt_re, local_si_ref,
                                    ) {
                                        Ok(p) => p,
                                        Err(_) => continue,
                                    }
                                }
                            };
                            local_stego_positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));

                            if verify_all_shadows_decoder_side(&local_img, &local_states, &eff_shadows, &local_stego_positions) {
                                if fraction > best_fraction {
                                    best_fraction = fraction;
                                }
                                successes.push((fraction, local_img, local_states, local_r.total_cost, local_r.num_modifications));
                            }
                        }

                        if !successes.is_empty() {
                            let best = successes.into_iter()
                                .max_by_key(|(f, _, _, _, _)| *f)
                                .unwrap();
                            img = best.1;
                            shadow_states = best.2;
                            stc_total_cost = best.3;
                            stc_num_modifications = best.4;
                            verified = true;
                            break 'parity_loop;
                        }

                        if has_fraction_1 && parity == 128 {
                            break 'parity_loop;
                        }
                    }
                }

                if !verified {
                    return Err(StegoError::MessageTooLarge);
                }
            }
        } // if !all_fraction_1
    }

    // Compute quality score with shadow penalty.
    let encode_quality = quality::ghost_stealth_score(&GhostMetrics {
        num_modifications: stc_num_modifications,
        n_used,
        w,
        total_cost: stc_total_cost,
        median_cost,
        is_si,
        shadow_modifications,
        total_coefficients,
    });

    // Free large allocations before JPEG output to reduce peak memory.
    drop(positions);
    drop(original_y);
    drop(shadow_states);
    drop(shadow_inf_costs);
    drop(si);

    // Write JPEG (with progress reporting during scan encoding).
    let progress_cb = || progress::advance();
    let stego_bytes = if let Ok(bytes) = img.to_bytes_with_progress(Some(&progress_cb)) { bytes } else {
        img.rebuild_huffman_tables();
        img.to_bytes_with_progress(Some(&progress_cb)).map_err(StegoError::InvalidJpeg)?
    };

    Ok((stego_bytes, encode_quality))
}

/// Run a single STC pass: restore Y grid, embed shadows, then run STC with optional ∞-cost.
///
/// Returns `(total_cost, num_modifications)` from STC embedding.
/// Result of a single STC embedding pass.
///
/// `modified_positions` is T3.2.A plumbing — the flat permute indices
/// of coefficients whose LSB the STC pass actually flipped. Forward
/// callers ignore it; the cascade-verify recompute path (T3.2.D+) uses
/// it to localize wavelet/cost re-computation to the dirty region.
struct StcPassResult {
    total_cost: f64,
    num_modifications: usize,
    modified_positions: Vec<u32>,
}

fn run_stc_pass(
    img: &mut JpegImage,
    original_y: &crate::codec::jpeg::dct::DctGrid,
    positions: &[permute::CoeffPos],
    shadow_states: &[shadow::ShadowState],
    message_bits: &[u8],
    hhat_matrix: &[Vec<u32>],
    w: usize,
    si: &Option<SideInfo>,
    shadow_inf_costs: &Option<std::collections::HashSet<u32>>,
) -> Result<StcPassResult, StegoError> {
    *img.dct_grid_mut(0) = original_y.clone();

    for state in shadow_states {
        shadow::embed_shadow_lsb(img, state);
    }

    let grid = img.dct_grid(0);
    let cover_bits: Vec<u8> = positions.iter().map(|p| {
        let coeff = flat_get(grid, p.flat_idx as usize);
        (coeff.unsigned_abs() & 1) as u8
    }).collect();

    // Build cost vector with ∞ for shadow positions when w >= 2.
    let costs: Vec<f32> = if let Some(inf_set) = shadow_inf_costs {
        positions.iter().map(|p| {
            if inf_set.contains(&p.flat_idx) {
                f32::INFINITY
            } else {
                p.cost
            }
        }).collect()
    } else {
        positions.iter().map(|p| p.cost).collect()
    };

    let result = embed::stc_embed(&cover_bits, &costs, message_bits, hhat_matrix, STC_H, w);
    progress::check_cancelled()?;
    let result = result.ok_or(StegoError::MessageTooLarge)?;

    let total_cost = result.total_cost;
    let num_modifications = result.num_modifications;

    let modified_positions = apply_stc_changes(img, positions, &cover_bits, &result.stego_bits, si);

    Ok(StcPassResult { total_cost, num_modifications, modified_positions })
}

/// Apply STC LSB changes to the DctGrid.
///
/// The STC inline path emits `stego_bits.len() == cover_bits.len() ==
/// positions.len()` (the standard case for images under
/// `SEGMENTED_THRESHOLD = 1_000_000` cover bits). The streaming-
/// segmented path (n > 1_000_000) emits only `stego_bits.len() == m * w`
/// — `positions[m*w..]` are not exercised by the Viterbi walk and stay
/// at their original LSB. Iterating only the intersection avoids an
/// index-out-of-bounds panic on 12 MP+ images that go through shadows
/// (which route through `run_stc_pass` → `apply_stc_changes`).
///
/// This mirrors the same defensive iteration in `ghost_encode_impl`
/// (the no-shadow path).
fn apply_stc_changes(
    img: &mut JpegImage,
    positions: &[permute::CoeffPos],
    cover_bits: &[u8],
    stego_bits: &[u8],
    si: &Option<SideInfo>,
) -> Vec<u32> {
    // T3.2.A — emit the flat indices of actually-modified positions.
    // The dirty-block UNIWARD re-cost (Phase B+) needs these to
    // identify which coefficients fall inside the 23×23 wavelet
    // impact region of a modification. For the no-modification
    // happy path (e.g. all `cover_bits == stego_bits`), this Vec
    // stays empty. See `docs/design/image/t3.2-dirty-block-recost.md`.
    let mut modified_positions = Vec::new();
    let grid_mut = img.dct_grid_mut(0);
    let n_modified = stego_bits.len();
    for (idx, pos) in positions.iter().take(n_modified).enumerate() {
        if cover_bits[idx] != stego_bits[idx] {
            let fi = pos.flat_idx as usize;
            let coeff = flat_get(grid_mut, fi);
            let modified = if let Some(side_info) = si {
                side_info::si_modify_coefficient(coeff, side_info.error_at(fi))
            } else {
                side_info::nsf5_modify_coefficient(coeff)
            };
            flat_set(grid_mut, fi, modified);
            modified_positions.push(pos.flat_idx);
        }
    }
    modified_positions
}

/// Check if all shadow layers can be decoded from the decoder's perspective.
///
/// Uses stego-image UNIWARD costs to select positions (simulating what the
/// decoder will do), then checks RS decode + AES-GCM decrypt.
///
/// T1.11 — parallelized via `par_iter().all(...)` (short-circuits on first
/// failure). Verifying N shadows used to be sequential; now N-way concurrent
/// up to the first failure. Each verify call does RS decode + AES decrypt
/// for one shadow, so N-shadow encodes get N× verify-phase speedup on the
/// happy path.
fn verify_all_shadows_decoder_side(
    img: &JpegImage,
    shadow_states: &[shadow::ShadowState],
    shadows: &[&ShadowLayer],
    stego_y_positions_sorted: &[permute::CoeffPos],
) -> bool {
    #[cfg(feature = "parallel")]
    {
        use rayon::prelude::*;
        shadow_states.par_iter().enumerate().all(|(i, state)| {
            shadow::verify_shadow_decoder_side(
                img, state, &shadows[i].passphrase, stego_y_positions_sorted,
            ).is_ok()
        })
    }
    #[cfg(not(feature = "parallel"))]
    {
        shadow_states.iter().enumerate().all(|(i, state)| {
            shadow::verify_shadow_decoder_side(
                img, state, &shadows[i].passphrase, stego_y_positions_sorted,
            ).is_ok()
        })
    }
}

/// Build the ∞-cost HashSet for shadow positions (used when w >= 2).
fn build_inf_cost_set(w: usize, shadow_states: &[shadow::ShadowState]) -> Option<std::collections::HashSet<u32>> {
    if w >= 2 && !shadow_states.is_empty() {
        let mut set = std::collections::HashSet::new();
        for state in shadow_states {
            for pos in &state.positions {
                set.insert(pos.flat_idx);
            }
        }
        Some(set)
    } else {
        None
    }
}

fn ghost_encode_impl(
    image_bytes: &[u8],
    message: &str,
    files: &[FileEntry],
    passphrase: &str,
    si: Option<SideInfo>,
    pre_parsed: Option<JpegImage>,
) -> Result<(Vec<u8>, EncodeQuality), StegoError> {
    // Initialize encode progress (100 UNIWARD + 50 STC + 2 misc).
    progress::init(GHOST_ENCODE_STEPS);

    // Build the payload (text + files + compression).
    let payload_bytes = payload::encode_payload(message, files)?;

    let mut img = match pre_parsed {
        Some(img) => img,
        None => JpegImage::from_bytes(image_bytes)?,
    };
    progress::advance_by(PARSE_STEPS);

    // Validate dimensions before any heavy processing.
    let fi = img.frame_info();
    crate::stego::validate_encode_dimensions(fi.width as u32, fi.height as u32)?;

    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    // 1. Compute J-UNIWARD costs strip-by-strip and collect positions directly.
    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
    #[cfg(feature = "parallel")]
    let key_thread = {
        let pass = passphrase.to_string();
        std::thread::spawn(move || crypto::derive_structural_key(&pass))
    };

    let qt_id = img.frame_info().components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
    let si_ref = si.as_ref().map(|s| (s, img.dct_grid(0)));
    let mut positions = compute_positions_streaming(img.dct_grid(0), qt, si_ref)?;

    // 2. Derive structural key (Tier 1).
    #[cfg(feature = "parallel")]
    let structural_key = key_thread.join().expect("key derivation thread")?;
    #[cfg(not(feature = "parallel"))]
    let structural_key = crypto::derive_structural_key(passphrase)?;
    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();

    // 3. Permute positions.
    permute::permute_positions(&mut positions, &perm_seed);
    let n = positions.len();

    // Step: permutation + key derivation complete.
    progress::advance();

    // 4. Encrypt payload (Tier 2 key with random salt).
    let (ciphertext, nonce, salt) = crypto::encrypt(&payload_bytes, passphrase)?;

    // 5. Build payload frame.
    let frame_bytes = frame::build_frame(payload_bytes.len(), &salt, &nonce, &ciphertext);
    let frame_bits = frame::bytes_to_bits(&frame_bytes);
    let m = frame_bits.len();

    // 6. Dynamic w: pick highest that fits the actual message (short STC).
    let w = (n / m).clamp(1, 10);
    let m_max = n / w;
    let n_used = m_max * w;

    if m > m_max {
        return Err(StegoError::MessageTooLarge);
    }

    // Memory budget check (same as compute_stc_params).
    if n_used > STC_POSITION_LIMIT {
        return Err(StegoError::ImageTooLarge);
    }

    positions.truncate(n_used);

    // 7. Extract cover LSBs and costs in permuted order.
    let grid = img.dct_grid(0);
    let cover_bits: Vec<u8> = positions.iter().map(|p| {
        let coeff = flat_get(grid, p.flat_idx as usize);
        (coeff.unsigned_abs() & 1) as u8
    }).collect();
    let costs: Vec<f32> = positions.iter().map(|p| p.cost).collect();

    // 8. Compute median cost (for quality score) before STC.
    let median_cost = {
        let mut finite_costs: Vec<f32> = costs.iter().copied().filter(|c| c.is_finite()).collect();
        if finite_costs.is_empty() {
            0.0f32
        } else {
            let mid = finite_costs.len() / 2;
            finite_costs.select_nth_unstable_by(mid, f32::total_cmp);
            finite_costs[mid]
        }
    };
    let is_si = si.is_some();

    // Total Y-channel coefficients for quality scoring.
    let grid_ref = img.dct_grid(0);
    let total_coefficients = grid_ref.blocks_wide() * grid_ref.blocks_tall() * 64;

    // 9. Generate H-hat and embed with short STC (actual m bits, not padded).
    let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);
    let result = embed::stc_embed(&cover_bits, &costs, &frame_bits, &hhat_matrix, STC_H, w);
    progress::check_cancelled()?;
    let result = result.ok_or(StegoError::MessageTooLarge)?;

    // 10. Compute encode quality score.
    let encode_quality = quality::ghost_stealth_score(&GhostMetrics {
        num_modifications: result.num_modifications,
        n_used,
        w,
        total_cost: result.total_cost,
        median_cost,
        is_si,
        shadow_modifications: 0,
        total_coefficients,
    });

    // 11. Apply LSB changes to DctGrid.
    //
    // The STC inline path emits `stego_bits.len() == cover_bits.len() == n_used`,
    // but the streaming-segmented path (n > 1_000_000) emits only
    // `stego_bits.len() == m * w` — positions[m*w..n_used] are not exercised
    // by the Viterbi walk and stay at their original LSB. Iterate the
    // intersection to avoid an index-out-of-bounds on big images (which
    // tests miss because test fixtures stay under the 1 M threshold and
    // route through the inline path).
    let grid_mut = img.dct_grid_mut(0);
    let n_modified = result.stego_bits.len();
    for (idx, pos) in positions.iter().take(n_modified).enumerate() {
        let old_bit = cover_bits[idx];
        let new_bit = result.stego_bits[idx];
        if old_bit != new_bit {
            let fi = pos.flat_idx as usize;
            let coeff = flat_get(grid_mut, fi);
            let modified = if let Some(ref side_info) = si {
                side_info::si_modify_coefficient(coeff, side_info.error_at(fi))
            } else {
                side_info::nsf5_modify_coefficient(coeff)
            };
            flat_set(grid_mut, fi, modified);
        }
    }

    // Free large allocations before JPEG output to reduce peak memory.
    drop(positions);
    drop(cover_bits);
    drop(costs);
    drop(result);
    drop(si);

    // 12. Write modified JPEG (with progress reporting during scan encoding).
    let progress_cb = || progress::advance();
    let stego_bytes = if let Ok(bytes) = img.to_bytes_with_progress(Some(&progress_cb)) { bytes } else {
        img.rebuild_huffman_tables();
        img.to_bytes_with_progress(Some(&progress_cb)).map_err(StegoError::InvalidJpeg)?
    };

    Ok((stego_bytes, encode_quality))
}

/// Decode a payload from a stego JPEG using Ghost mode.
///
/// Returns the decoded text and any embedded files.
///
/// # Errors
/// - [`StegoError::DecryptionFailed`] if the passphrase is wrong.
/// - [`StegoError::FrameCorrupted`] if the CRC check fails.
/// - [`StegoError::InvalidUtf8`] if the decrypted payload is not valid UTF-8.
pub fn ghost_decode(
    stego_bytes: &[u8],
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    let img = JpegImage::from_bytes(stego_bytes)?;
    progress::advance_by(PARSE_STEPS);
    ghost_decode_from_image(&img, passphrase)
}

/// Ghost decode against an already-parsed JPEG. Used by `smart_decode`'s
/// shared-parse path (T1.5) to avoid re-parsing the same JPEG across
/// the Ghost / Armor / Fortress / Shadow attempt sequence.
pub fn ghost_decode_from_image(
    img: &JpegImage,
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    // 1. Compute J-UNIWARD costs strip-by-strip and collect positions directly.
    // Overlap Argon2id structural key derivation (~200ms) with UNIWARD (1-10s).
    // Argon2 result discarded — `_with_positions` re-derives with T1.1
    // thread-local cache (worker stays warm across smart_decode calls).
    #[cfg(feature = "parallel")]
    let key_thread = {
        let pass = passphrase.to_string();
        std::thread::spawn(move || crypto::derive_structural_key(&pass))
    };

    let qt_id = img.frame_info().components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
    let positions = compute_positions_streaming(img.dct_grid(0), qt, None)?;

    #[cfg(feature = "parallel")]
    let _ = key_thread.join();

    ghost_decode_from_image_with_positions(img, passphrase, positions)
}

/// Ghost decode given pre-computed positions (T1.8). Used by `smart_decode`
/// to share Y-channel UNIWARD positions between Ghost primary and Ghost
/// shadow — both consumers take ownership of their own clone since both
/// mutate (permute vs sort).
///
/// The Argon2id structural-key derivation uses T1.1's thread-local cache;
/// the overlap-with-UNIWARD pattern in the public wrapper above is for
/// the standalone-call path where the cache may be cold.
pub fn ghost_decode_from_image_with_positions(
    img: &JpegImage,
    passphrase: &str,
    mut positions: Vec<CoeffPos>,
) -> Result<PayloadData, StegoError> {
    progress::check_cancelled()?;

    // Derive structural key (T1.1 cache).
    let structural_key = crypto::derive_structural_key(passphrase)?;
    let perm_seed: [u8; 32] = structural_key[..32].try_into().unwrap();
    let hhat_seed: [u8; 32] = structural_key[32..].try_into().unwrap();

    // Permute positions.
    permute::permute_positions(&mut positions, &perm_seed);
    let n = positions.len();
    progress::advance();

    // Extract all stego LSBs (full n, reused across w candidates).
    let all_stego_bits: Vec<u8> = {
        let grid = img.dct_grid(0);
        positions.iter().map(|p| {
            let coeff = flat_get(grid, p.flat_idx as usize);
            (coeff.unsigned_abs() & 1) as u8
        }).collect()
    };
    // Drop positions immediately — only stego_bits + hhat_seed needed
    // for the brute-force `w` loop below. Frees ~50 MB on 200 MP.
    drop(positions);

    // 5. Brute-force w: try the natural w first (backward compat), then dynamic candidates.
    let w_natural = compute_stc_params(n).map(|(w, _, _)| w).unwrap_or(1);
    let w_candidates_raw: &[usize] = &[w_natural, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // Deduplicate and filter w candidates up front.
    let mut deduped_w: Vec<usize> = Vec::with_capacity(w_candidates_raw.len());
    {
        let mut tried_w = 0u16;
        for &w in w_candidates_raw {
            if w == 0 || n / w == 0 {
                continue;
            }
            if w <= 15 && (tried_w & (1 << w)) != 0 {
                continue;
            }
            if w <= 15 {
                tried_w |= 1 << w;
            }
            let n_used = (n / w) * w;
            if n_used > all_stego_bits.len() {
                continue;
            }
            deduped_w.push(w);
        }
    }

    #[cfg(feature = "parallel")]
    {
        use rayon::prelude::*;
        let result = deduped_w.par_iter().find_map_first(|&w| {
            let m_max = n / w;
            let n_used = m_max * w;

            let stego_bits = &all_stego_bits[..n_used];
            let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);

            // T1.4 — length-prefix gate. Skip wrong-w candidates after
            // ~16 syndrome XORs instead of the full m_max × w.
            if !w_length_prefix_consistent(stego_bits, &hhat_matrix, w, m_max) {
                return None;
            }

            let extracted_bits = extract::stc_extract(stego_bits, &hhat_matrix, w);

            let frame_bytes = frame::bits_to_bytes(&extracted_bits[..m_max]);
            match try_parse_and_decrypt(&frame_bytes, passphrase) {
                Ok(payload) => Some(Ok(payload)),
                Err(StegoError::DecryptionFailed) => Some(Err(StegoError::DecryptionFailed)),
                Err(_) => None,
            }
        });
        match result {
            Some(Ok(payload)) => {
                progress::advance();
                return Ok(payload);
            }
            Some(Err(e)) => {
                progress::advance();
                return Err(e);
            }
            None => {
                // Fall through to error below.
            }
        }
    }
    #[cfg(not(feature = "parallel"))]
    {
        let mut saw_decrypt_fail = false;
        for &w in &deduped_w {
            let m_max = n / w;
            let n_used = m_max * w;

            let stego_bits = &all_stego_bits[..n_used];
            let hhat_matrix = hhat::generate_hhat(STC_H, w, &hhat_seed);

            // T1.4 — length-prefix gate. Skip wrong-w candidates after
            // ~16 syndrome XORs instead of the full m_max × w.
            if !w_length_prefix_consistent(stego_bits, &hhat_matrix, w, m_max) {
                continue;
            }

            let extracted_bits = extract::stc_extract(stego_bits, &hhat_matrix, w);

            let frame_bytes = frame::bits_to_bytes(&extracted_bits[..m_max]);
            match try_parse_and_decrypt(&frame_bytes, passphrase) {
                Ok(payload) => {
                    progress::advance();
                    return Ok(payload);
                }
                Err(StegoError::DecryptionFailed) => {
                    saw_decrypt_fail = true;
                }
                Err(_) => {}
            }
        }

        // Step: STC extraction + decryption complete (all attempts failed).
        progress::advance();

        if saw_decrypt_fail {
            return Err(StegoError::DecryptionFailed);
        }
    }

    // Step: STC extraction + decryption complete (all attempts failed).
    #[cfg(feature = "parallel")]
    progress::advance();

    Err(StegoError::FrameCorrupted)
}

/// T1.4 — length-prefix early-reject for the brute-force `w` loop.
///
/// Reads the first 16 (and if needed 48) STC syndrome bits via
/// [`extract::stc_extract_prefix`] and checks the v1/v2 length-field
/// against the candidate `m_max` (= `n / w`). For wrong `w` the
/// partial extract is essentially random; the gate rejects most
/// wrong candidates in `O(16 × w)` syndrome XORs instead of the
/// full `O(m_max × w)` of a full extract + frame parse + AES.
///
/// **Inequality (≤), not equality, vs the H.264 reference**: Ghost's
/// encoder picks `w = (n / m).clamp(1, 10)`, so when `w` is clamped
/// at 10 (short messages on large covers), `m_max = n/10 > m`. The
/// encoder embeds only `m` syndrome rows; decoder always extracts
/// `m_max`. Frame fits in `m_max` ⇔
/// `(FRAME_OVERHEAD + plaintext_len) * 8 <= m_max`. For wrong-`w`
/// candidates the random prefix gives `pt_len` uniform in [1, u16::MAX];
/// fraction passing the inequality scales with `m_max / max_frame_bits`
/// ≈ 1-10% on typical 1080p–12MP covers — still a meaningful gate.
///
/// Mirrors the H.264 #516.2 pattern in `openh264_stego.rs` /
/// `streaming_session.rs` (same call to `stc_extract_prefix`, weaker
/// identity to accommodate Ghost's `m < m_max` slack).
fn w_length_prefix_consistent(
    stego_bits: &[u8],
    hhat_matrix: &[Vec<u32>],
    w: usize,
    m_max: usize,
) -> bool {
    let prefix = extract::stc_extract_prefix(stego_bits, hhat_matrix, w, 16);
    if prefix.len() < 16 {
        return false;
    }
    let prefix_bytes = frame::bits_to_bytes(&prefix);
    let v1_len = u16::from_be_bytes([prefix_bytes[0], prefix_bytes[1]]) as usize;
    if v1_len > 0 && (frame::FRAME_OVERHEAD + v1_len) * 8 <= m_max {
        return true;
    }
    // v2 sentinel: first u16 == 0 → read u32 length at bits 16..48.
    // Rare (>64 KB plaintext); the 16-bit fast path covers all
    // messages ≤ 65535 bytes. v2_len > u16::MAX disambiguates from a
    // legitimate v1 plaintext_len = 0 (which encoder shouldn't emit
    // anyway).
    if v1_len == 0 {
        let ext = extract::stc_extract_prefix(stego_bits, hhat_matrix, w, 48);
        if ext.len() >= 48 {
            let ext_bytes = frame::bits_to_bytes(&ext);
            let v2_len = u32::from_be_bytes([
                ext_bytes[2],
                ext_bytes[3],
                ext_bytes[4],
                ext_bytes[5],
            ]) as usize;
            if v2_len > u16::MAX as usize
                && (frame::FRAME_OVERHEAD_EXT + v2_len) * 8 <= m_max
            {
                return true;
            }
        }
    }
    false
}

/// Helper: parse frame, decrypt, and decode payload. Used by brute-force w loop.
fn try_parse_and_decrypt(
    frame_bytes: &[u8],
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    let parsed = frame::parse_frame(frame_bytes)?;
    let plaintext = crypto::decrypt(
        &parsed.ciphertext,
        passphrase,
        &parsed.salt,
        &parsed.nonce,
    )?;
    let len = parsed.plaintext_len as usize;
    if len > plaintext.len() {
        return Err(StegoError::FrameCorrupted);
    }
    payload::decode_payload(&plaintext[..len])
}

/// Decode a shadow message from a stego JPEG.
///
/// Shadow uses cost-pool position selection (UNIWARD costs for stealth)
/// plus hash permutation and RS error correction.
pub fn ghost_shadow_decode(
    stego_bytes: &[u8],
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    let img = JpegImage::from_bytes(stego_bytes)?;
    ghost_shadow_decode_from_image(&img, passphrase)
}

/// Decode a shadow message from an already-parsed JPEG image.
///
/// Computes Y-channel UNIWARD costs, sorts by cost (cheapest first), and
/// passes to the shadow extractor which brute-forces (fraction, parity, fdl).
pub fn ghost_shadow_decode_from_image(
    img: &JpegImage,
    passphrase: &str,
) -> Result<PayloadData, StegoError> {
    if img.num_components() == 0 {
        return Err(StegoError::NoLuminanceChannel);
    }

    // Compute Y-channel UNIWARD costs for cost-pool selection.
    let qt_id = img.frame_info().components[0].quant_table_id as usize;
    let qt = img.quant_table(qt_id).ok_or(StegoError::NoLuminanceChannel)?;
    let positions = compute_positions_streaming(img.dct_grid(0), qt, None)?;
    ghost_shadow_decode_from_image_with_positions(img, passphrase, positions)
}

/// Decode a shadow message given pre-computed positions (T1.8). Used by
/// `smart_decode` to share Y-channel UNIWARD positions with Ghost primary.
/// Takes ownership of `positions` since the cost-sort mutates them in
/// place.
pub fn ghost_shadow_decode_from_image_with_positions(
    img: &JpegImage,
    passphrase: &str,
    mut positions: Vec<CoeffPos>,
) -> Result<PayloadData, StegoError> {
    // Sort by cost (cheapest first) for cost-pool tier selection.
    positions.sort_by(|a, b| a.cost.total_cmp(&b.cost));
    shadow::shadow_extract(img, &positions, passphrase)
}

// --- DctGrid flat access helpers ---

use crate::codec::jpeg::dct::DctGrid;

/// Read a coefficient from a `DctGrid` using a flat index.
///
/// The flat index encodes `block_index * 64 + row * 8 + col`, where
/// `block_index = block_row * blocks_wide + block_col`.
pub(super) fn flat_get(grid: &DctGrid, flat_idx: usize) -> i16 {
    let bw = grid.blocks_wide();
    let block_idx = flat_idx / 64;
    let pos = flat_idx % 64;
    let br = block_idx / bw;
    let bc = block_idx % bw;
    let i = pos / 8;
    let j = pos % 8;
    grid.get(br, bc, i, j)
}

/// Write a coefficient into a `DctGrid` using a flat index.
pub(super) fn flat_set(grid: &mut DctGrid, flat_idx: usize, val: i16) {
    let bw = grid.blocks_wide();
    let block_idx = flat_idx / 64;
    let pos = flat_idx % 64;
    let br = block_idx / bw;
    let bc = block_idx % bw;
    let i = pos / 8;
    let j = pos % 8;
    grid.set(br, bc, i, j, val);
}
