Skip to main content

ad_plugins_rs/
codec.rs

1// RTEMS-EXEC-MODEL-ALLOW(1): checked, not waived — all 1 ran and passed
2// on the exec backend (measured on this tree:
3// `EPICS_RS_BUILD_EXEC_BACKEND=thread cargo nextest run -p ad-plugins-rs
4// --all-features`, 556/556). ad-plugins-rs became a census subject when
5// its `build.rs` began deriving `tokio_backend`; nothing here builds a
6// CA server, and the reactor these obtain comes from `#[tokio::test]`
7// itself, which the backend does not remove.
8use std::borrow::Cow;
9use std::io::{Read, Write};
10use std::sync::Arc;
11
12use ad_core_rs::codec::{Codec, CodecName, CodecStatus};
13use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
14use ad_core_rs::ndarray_pool::NDArrayPool;
15use ad_core_rs::plugin::runtime::{NDPluginProcess, ParamUpdate, ProcessResult};
16
17use flate2::Compression;
18use flate2::read::ZlibDecoder;
19use flate2::write::ZlibEncoder;
20use lz4_flex::block::{compress, decompress};
21use parking_lot::Mutex;
22use rust_hdf5::format::messages::filter::{
23    FILTER_BLOSC, Filter, FilterPipeline, apply_filters, reverse_filters,
24};
25
26/// The original (uncompressed) element type of an NDArray.
27///
28/// For an uncompressed array this is the buffer's own type. For a compressed
29/// array the typed buffer has collapsed to raw bytes (`UInt8`), so the original
30/// type is read from [`Codec::original_data_type`], which the codec plugin set
31/// on compress — mirroring C ADCore keeping it in `NDArray::dataType`
32/// (NDPluginCodec.cpp:35-36). Shared by the decompress round-trip and the
33/// NTNDArray converter, which needs it to publish `uncompressedSize` and
34/// `codec.parameters` (C `NDDataTypeToScalar[src->dataType]`,
35/// ntndArrayConverter.cpp:413-419) since a compressed array's value union no
36/// longer carries the element type.
37pub fn original_data_type(array: &NDArray) -> NDDataType {
38    match &array.codec {
39        Some(c) => c.original_data_type,
40        None => array.data.data_type(),
41    }
42}
43
44/// Reconstruct an `NDDataBuffer` from raw bytes and a target data type.
45///
46/// The byte slice is reinterpreted as the target type using native endianness.
47/// Returns `None` if the byte count is not a multiple of the element size.
48pub(crate) fn buffer_from_bytes(bytes: &[u8], data_type: NDDataType) -> Option<NDDataBuffer> {
49    let elem_size = data_type.element_size();
50    if bytes.len() % elem_size != 0 {
51        return None;
52    }
53    let count = bytes.len() / elem_size;
54
55    Some(match data_type {
56        NDDataType::Int8 => {
57            let mut v = vec![0i8; count];
58            // SAFETY: i8 and u8 have the same size/alignment
59            unsafe {
60                std::ptr::copy_nonoverlapping(
61                    bytes.as_ptr(),
62                    v.as_mut_ptr() as *mut u8,
63                    bytes.len(),
64                );
65            }
66            NDDataBuffer::I8(v)
67        }
68        NDDataType::UInt8 => NDDataBuffer::U8(bytes.to_vec()),
69        NDDataType::Int16 => {
70            let mut v = vec![0i16; count];
71            unsafe {
72                std::ptr::copy_nonoverlapping(
73                    bytes.as_ptr(),
74                    v.as_mut_ptr() as *mut u8,
75                    bytes.len(),
76                );
77            }
78            NDDataBuffer::I16(v)
79        }
80        NDDataType::UInt16 => {
81            let mut v = vec![0u16; count];
82            unsafe {
83                std::ptr::copy_nonoverlapping(
84                    bytes.as_ptr(),
85                    v.as_mut_ptr() as *mut u8,
86                    bytes.len(),
87                );
88            }
89            NDDataBuffer::U16(v)
90        }
91        NDDataType::Int32 => {
92            let mut v = vec![0i32; count];
93            unsafe {
94                std::ptr::copy_nonoverlapping(
95                    bytes.as_ptr(),
96                    v.as_mut_ptr() as *mut u8,
97                    bytes.len(),
98                );
99            }
100            NDDataBuffer::I32(v)
101        }
102        NDDataType::UInt32 => {
103            let mut v = vec![0u32; count];
104            unsafe {
105                std::ptr::copy_nonoverlapping(
106                    bytes.as_ptr(),
107                    v.as_mut_ptr() as *mut u8,
108                    bytes.len(),
109                );
110            }
111            NDDataBuffer::U32(v)
112        }
113        NDDataType::Int64 => {
114            let mut v = vec![0i64; count];
115            unsafe {
116                std::ptr::copy_nonoverlapping(
117                    bytes.as_ptr(),
118                    v.as_mut_ptr() as *mut u8,
119                    bytes.len(),
120                );
121            }
122            NDDataBuffer::I64(v)
123        }
124        NDDataType::UInt64 => {
125            let mut v = vec![0u64; count];
126            unsafe {
127                std::ptr::copy_nonoverlapping(
128                    bytes.as_ptr(),
129                    v.as_mut_ptr() as *mut u8,
130                    bytes.len(),
131                );
132            }
133            NDDataBuffer::U64(v)
134        }
135        NDDataType::Float32 => {
136            let mut v = vec![0f32; count];
137            unsafe {
138                std::ptr::copy_nonoverlapping(
139                    bytes.as_ptr(),
140                    v.as_mut_ptr() as *mut u8,
141                    bytes.len(),
142                );
143            }
144            NDDataBuffer::F32(v)
145        }
146        NDDataType::Float64 => {
147            let mut v = vec![0f64; count];
148            unsafe {
149                std::ptr::copy_nonoverlapping(
150                    bytes.as_ptr(),
151                    v.as_mut_ptr() as *mut u8,
152                    bytes.len(),
153                );
154            }
155            NDDataBuffer::F64(v)
156        }
157    })
158}
159
160/// Compress an NDArray using LZ4.
161///
162/// The raw bytes of the data buffer are compressed with LZ4 (block mode, size-prepended).
163/// The original data type ordinal is stored as an attribute so decompression can
164/// reconstruct the correct typed buffer.
165pub fn compress_lz4(src: &NDArray) -> NDArray {
166    let raw = src.data.as_u8_slice();
167    let original_data_type = src.data.data_type();
168    let original_size = raw.len();
169    // C++ uses raw LZ4_compress_default (no size header)
170    let compressed = compress(raw);
171    let compressed_size = compressed.len();
172
173    let mut arr = src.clone();
174    arr.data = NDDataBuffer::U8(compressed);
175    arr.codec = Some(Codec {
176        name: CodecName::LZ4,
177        compressed_size,
178        level: 0,
179        shuffle: 0,
180        compressor: 0,
181        // The original element type travels in the codec (C `NDArray::dataType`,
182        // NDPluginCodec.cpp:35-36), so decompression can rebuild the buffer.
183        original_data_type,
184    });
185
186    tracing::debug!(
187        original_size,
188        compressed_size,
189        ratio = original_size as f64 / compressed_size.max(1) as f64,
190        "LZ4 compress"
191    );
192
193    arr
194}
195
196/// Decompress an LZ4-compressed NDArray.
197///
198/// Returns `None` if the codec is not LZ4 or decompression fails.
199/// The original typed buffer is reconstructed using the stored data type attribute.
200pub fn decompress_lz4(src: &NDArray) -> Option<NDArray> {
201    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::LZ4) {
202        return None;
203    }
204    let compressed = src.data.as_u8_slice();
205    // C++ uses LZ4_decompress_fast with a known uncompressed size; the original
206    // element type travels in the codec (C `NDArray::dataType`).
207    let original_type = original_data_type(src);
208    let num_elements: usize = src.dims.iter().map(|d| d.size).product();
209    let uncompressed_size = num_elements * original_type.element_size();
210    let decompressed = decompress(compressed, uncompressed_size).ok()?;
211
212    let buffer = buffer_from_bytes(&decompressed, original_type)?;
213
214    let mut arr = src.clone();
215    arr.data = buffer;
216    arr.codec = None;
217
218    Some(arr)
219}
220
221// ---------------------------------------------------------------------------
222// Zlib (deflate) — port of the C++ NDCodec ZLIB codec
223// ---------------------------------------------------------------------------
224//
225// C++ `compressZlib`/`decompressZlib` call zlib `compress2`/`uncompress` on the
226// raw element bytes. We use `flate2`'s `ZlibEncoder`/`ZlibDecoder`, which emit
227// and parse the same zlib (RFC 1950) stream. The original data type is stored
228// as an attribute so decompression can rebuild the typed buffer.
229
230/// Default zlib compression level (mirrors `Compression::default()`, level 6).
231const ZLIB_DEFAULT_LEVEL: u32 = 6;
232
233/// Compress an NDArray using zlib (deflate).
234///
235/// Mirrors C++ `compressZlib`. The raw bytes of the data buffer are compressed
236/// with a zlib stream. The original data type ordinal is stored as an attribute
237/// so decompression can reconstruct the correct typed buffer.
238pub fn compress_zlib(src: &NDArray) -> NDArray {
239    let raw = src.data.as_u8_slice();
240    let original_data_type = src.data.data_type();
241    let original_size = raw.len();
242
243    let mut encoder = ZlibEncoder::new(Vec::<u8>::new(), Compression::new(ZLIB_DEFAULT_LEVEL));
244    // Writing to a `Vec` and finishing the stream are infallible here.
245    if encoder.write_all(raw).is_err() {
246        return src.clone();
247    }
248    let compressed = match encoder.finish() {
249        Ok(buf) => buf,
250        Err(_) => return src.clone(),
251    };
252    let compressed_size = compressed.len();
253
254    let mut arr = src.clone();
255    arr.data = NDDataBuffer::U8(compressed);
256    arr.codec = Some(Codec {
257        name: CodecName::Zlib,
258        compressed_size,
259        level: ZLIB_DEFAULT_LEVEL as i32,
260        shuffle: 0,
261        compressor: 0,
262        original_data_type,
263    });
264
265    tracing::debug!(
266        original_size,
267        compressed_size,
268        ratio = original_size as f64 / compressed_size.max(1) as f64,
269        "Zlib compress"
270    );
271    arr
272}
273
274/// Decompress a zlib-compressed NDArray.
275///
276/// Returns `None` if the codec is not Zlib or decompression fails.
277/// The original typed buffer is reconstructed using the stored data type attribute.
278pub fn decompress_zlib(src: &NDArray) -> Option<NDArray> {
279    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::Zlib) {
280        return None;
281    }
282    let compressed = src.data.as_u8_slice();
283
284    let original_type = original_data_type(src);
285    let num_elements: usize = src.dims.iter().map(|d| d.size).product();
286    let uncompressed_size = num_elements * original_type.element_size();
287
288    let mut decoder = ZlibDecoder::new(compressed);
289    let mut decompressed = Vec::with_capacity(uncompressed_size);
290    decoder.read_to_end(&mut decompressed).ok()?;
291
292    let buffer = buffer_from_bytes(&decompressed, original_type)?;
293
294    let mut arr = src.clone();
295    arr.data = buffer;
296    arr.codec = None;
297    Some(arr)
298}
299
300// ---------------------------------------------------------------------------
301// LZ4HDF5 — port of the C++ NDCodec LZ4HDF5 codec
302// ---------------------------------------------------------------------------
303//
304// C++ `compressLZ4`/`decompressLZ4` (the HAVE_BITSHUFFLE LZ4 variant) use the
305// HDF5 LZ4 filter block framing. The container layout is:
306//
307//   8 bytes  total uncompressed size  (big-endian u64)
308//   4 bytes  block size in bytes      (big-endian u32)
309//   then, per block:
310//     4 bytes  compressed block byte length (big-endian u32)
311//     LZ4-block-compressed payload
312//
313// Each block compresses up to `block_size` raw bytes with the LZ4 block codec.
314// The HDF5 LZ4 filter stores a block uncompressed when LZ4 does not shrink it;
315// the framed length then equals the raw block length, which decompression uses
316// to detect and copy the block verbatim.
317
318/// Default LZ4HDF5 block size in bytes (HDF5 LZ4 filter `DEFAULT_BLOCK_SIZE`, 1 MiB).
319const LZ4HDF5_DEFAULT_BLOCK_SIZE: usize = 1 << 20;
320
321/// Compress an NDArray with the HDF5 LZ4 filter framing (`lz4hdf5`).
322///
323/// Mirrors C++ `compressLZ4` (the HDF5 LZ4 filter variant). The raw data buffer
324/// is split into fixed-size blocks, each LZ4-block-compressed, and the HDF5 LZ4
325/// container header is prepended. The original data type is stored as an
326/// attribute so decompression can rebuild the typed buffer.
327pub fn compress_lz4hdf5(src: &NDArray) -> NDArray {
328    let raw = src.data.as_u8_slice();
329    let data_type = src.data.data_type();
330    let original_size = raw.len();
331    let block_size = LZ4HDF5_DEFAULT_BLOCK_SIZE;
332
333    // HDF5 LZ4 header: 8-byte total uncompressed size, 4-byte block size.
334    let mut out: Vec<u8> = Vec::with_capacity(original_size / 2 + 12);
335    out.extend_from_slice(&(original_size as u64).to_be_bytes());
336    out.extend_from_slice(&(block_size as u32).to_be_bytes());
337
338    let mut pos = 0usize;
339    while pos < raw.len() {
340        let n = block_size.min(raw.len() - pos);
341        let block = &raw[pos..pos + n];
342        let comp = compress(block);
343        // The HDF5 LZ4 filter stores the block uncompressed when LZ4 does not
344        // shrink it; the framed length then equals the raw block length.
345        if comp.len() < n {
346            out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
347            out.extend_from_slice(&comp);
348        } else {
349            out.extend_from_slice(&(n as u32).to_be_bytes());
350            out.extend_from_slice(block);
351        }
352        pos += n;
353    }
354
355    let compressed_size = out.len();
356    let mut arr = src.clone();
357    arr.data = NDDataBuffer::U8(out);
358    arr.codec = Some(Codec {
359        name: CodecName::LZ4HDF5,
360        compressed_size,
361        level: 0,
362        shuffle: 0,
363        compressor: 0,
364        original_data_type: data_type,
365    });
366
367    tracing::debug!(
368        original_size,
369        compressed_size,
370        ratio = original_size as f64 / compressed_size.max(1) as f64,
371        "LZ4HDF5 compress"
372    );
373    arr
374}
375
376/// Decompress an LZ4HDF5-compressed NDArray.
377///
378/// Returns `None` if the codec is not LZ4HDF5 or the container is malformed.
379pub fn decompress_lz4hdf5(src: &NDArray) -> Option<NDArray> {
380    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::LZ4HDF5) {
381        return None;
382    }
383    let buf = src.data.as_u8_slice();
384    if buf.len() < 12 {
385        return None;
386    }
387    let total_bytes = u64::from_be_bytes(buf[0..8].try_into().ok()?) as usize;
388    let block_size = u32::from_be_bytes(buf[8..12].try_into().ok()?) as usize;
389    if block_size == 0 {
390        return None;
391    }
392
393    let original_type = original_data_type(src);
394
395    let mut out: Vec<u8> = Vec::with_capacity(total_bytes);
396    let mut pos = 12usize;
397    while out.len() < total_bytes {
398        let n = block_size.min(total_bytes - out.len());
399        if pos + 4 > buf.len() {
400            return None;
401        }
402        let clen = u32::from_be_bytes(buf[pos..pos + 4].try_into().ok()?) as usize;
403        pos += 4;
404        if pos + clen > buf.len() {
405            return None;
406        }
407        let block_payload = &buf[pos..pos + clen];
408        if clen == n {
409            // Block was stored uncompressed (LZ4 did not shrink it).
410            out.extend_from_slice(block_payload);
411        } else {
412            let block = decompress(block_payload, n).ok()?;
413            if block.len() != n {
414                return None;
415            }
416            out.extend_from_slice(&block);
417        }
418        pos += clen;
419    }
420    if out.len() != total_bytes {
421        return None;
422    }
423
424    let buffer = buffer_from_bytes(&out, original_type)?;
425    let mut arr = src.clone();
426    arr.data = buffer;
427    arr.codec = None;
428    Some(arr)
429}
430
431// ---------------------------------------------------------------------------
432// Bitshuffle / LZ4 (bslz4) — port of the C++ NDCodec BSLZ4 codec
433// ---------------------------------------------------------------------------
434//
435// C++ `compressBSLZ4`/`decompressBSLZ4` call `bshuf_compress_lz4` /
436// `bshuf_decompress_lz4` from the Bitshuffle library. We reproduce both the
437// bitshuffle bit-transpose and the bslz4 container format here so the output
438// is byte-compatible with the HDF5 `bslz4` filter:
439//
440//   8 bytes  total uncompressed size  (big-endian u64)
441//   4 bytes  block size in elements   (big-endian u32)
442//   then, per block:
443//     4 bytes  compressed block byte length (big-endian u32)
444//     LZ4-block-compressed, bit-shuffled block payload
445//
446// Bitshuffle transposes the *bit* matrix of a block: a block of `n` elements
447// of `elem_size` bytes is viewed as an `n` x `(elem_size*8)` bit matrix and
448// transposed to `(elem_size*8)` x `n`. Bitshuffle requires the per-block
449// element count to be a multiple of 8 for the bit transpose; a trailing
450// partial block is byte-transposed only (this matches the reference library).
451
452/// Bitshuffle target block size in bytes (library `BSHUF_TARGET_BLOCK_SIZE_B`).
453const BSHUF_TARGET_BLOCK_SIZE_B: usize = 8192;
454/// Block element count must be a multiple of this (`BSHUF_BLOCKED_MULT`).
455const BSHUF_BLOCKED_MULT: usize = 8;
456/// Recommended minimum block size in elements (`BSHUF_MIN_RECOMMEND_BLOCK`).
457const BSHUF_MIN_RECOMMEND_BLOCK: usize = 128;
458
459/// Default bitshuffle block size in elements for a given element size.
460///
461/// Mirrors `bshuf_default_block_size` (bitshuffle_core.c:1828): `TARGET /
462/// elem_size` rounded down to a multiple of `BSHUF_BLOCKED_MULT`, floored at
463/// `BSHUF_MIN_RECOMMEND_BLOCK`. This value must stay stable across versions or
464/// previously-encoded streams become undecodable.
465pub(crate) fn bshuf_default_block_size(elem_size: usize) -> usize {
466    let bs = BSHUF_TARGET_BLOCK_SIZE_B / elem_size.max(1);
467    let bs = (bs / BSHUF_BLOCKED_MULT) * BSHUF_BLOCKED_MULT;
468    bs.max(BSHUF_MIN_RECOMMEND_BLOCK)
469}
470
471/// 8x8 bit-matrix transpose of a quadword, little-endian convention
472/// (library macro `TRANS_BIT_8X8`, bitshuffle_core.c:89).
473#[inline]
474fn trans_bit_8x8(mut x: u64) -> u64 {
475    let t = (x ^ (x >> 7)) & 0x00AA_00AA_00AA_00AA;
476    x = x ^ t ^ (t << 7);
477    let t = (x ^ (x >> 14)) & 0x0000_CCCC_0000_CCCC;
478    x = x ^ t ^ (t << 14);
479    let t = (x ^ (x >> 28)) & 0x0000_0000_F0F0_F0F0;
480    x = x ^ t ^ (t << 28);
481    x
482}
483
484/// Read 8 bytes at `off` as a little-endian quadword.
485#[inline]
486fn read_u64_le(b: &[u8], off: usize) -> u64 {
487    u64::from_le_bytes(b[off..off + 8].try_into().unwrap())
488}
489
490/// Transpose bytes within elements (library `bshuf_trans_byte_elem_scal`,
491/// bitshuffle_core.c:174). `size` is a multiple of 8 for every shuffled block.
492fn bshuf_trans_byte_elem(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
493    let mut ii = 0;
494    while ii + 7 < size {
495        for jj in 0..elem_size {
496            for kk in 0..8 {
497                out[jj * size + ii + kk] = input[ii * elem_size + kk * elem_size + jj];
498            }
499        }
500        ii += 8;
501    }
502    // Remainder (size % 8); never taken for a shuffled block but kept faithful.
503    let mut ii = size - size % 8;
504    while ii < size {
505        for jj in 0..elem_size {
506            out[jj * size + ii] = input[ii * elem_size + jj];
507        }
508        ii += 1;
509    }
510}
511
512/// Transpose bits within bytes (library `bshuf_trans_bit_byte_scal`,
513/// bitshuffle_core.c:219, little-endian path).
514fn bshuf_trans_bit_byte(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
515    let nbyte = elem_size * size;
516    let nbyte_bitrow = nbyte / 8;
517    for ii in 0..nbyte_bitrow {
518        let mut x = trans_bit_8x8(read_u64_le(input, ii * 8));
519        for kk in 0..8 {
520            out[kk * nbyte_bitrow + ii] = x as u8;
521            x >>= 8;
522        }
523    }
524}
525
526/// Transpose rows of shuffled bits within groups of eight (library
527/// `bshuf_trans_bitrow_eight` -> `bshuf_trans_elem`, lda=8, ldb=elem_size).
528fn bshuf_trans_bitrow_eight(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
529    let nbyte_bitrow = size / 8;
530    for ii in 0..8 {
531        for jj in 0..elem_size {
532            let src = (ii * elem_size + jj) * nbyte_bitrow;
533            let dst = (jj * 8 + ii) * nbyte_bitrow;
534            out[dst..dst + nbyte_bitrow].copy_from_slice(&input[src..src + nbyte_bitrow]);
535        }
536    }
537}
538
539/// Bit-transpose one block of `size` elements (a multiple of 8) — library
540/// `bshuf_trans_bit_elem_scal` (bitshuffle_core.c:256): byte transpose, then
541/// bit-within-byte transpose, then bit-row transpose.
542fn bshuf_trans_bit_elem(input: &[u8], size: usize, elem_size: usize) -> Vec<u8> {
543    debug_assert_eq!(size % 8, 0);
544    let nbyte = size * elem_size;
545    let mut a = vec![0u8; nbyte];
546    bshuf_trans_byte_elem(input, &mut a, size, elem_size);
547    let mut b = vec![0u8; nbyte];
548    bshuf_trans_bit_byte(&a, &mut b, size, elem_size);
549    let mut out = vec![0u8; nbyte];
550    bshuf_trans_bitrow_eight(&b, &mut out, size, elem_size);
551    out
552}
553
554/// Transpose bytes for data organized as one row per bit (library
555/// `bshuf_trans_byte_bitrow_scal`, bitshuffle_core.c:281).
556fn bshuf_trans_byte_bitrow(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
557    let nbyte_row = size / 8;
558    for jj in 0..elem_size {
559        for ii in 0..nbyte_row {
560            for kk in 0..8 {
561                out[ii * 8 * elem_size + jj * 8 + kk] = input[(jj * 8 + kk) * nbyte_row + ii];
562            }
563        }
564    }
565}
566
567/// Shuffle bits within the bytes of eight-element groups (library
568/// `bshuf_shuffle_bit_eightelem_scal`, bitshuffle_core.c:308, LE path).
569fn bshuf_shuffle_bit_eightelem(input: &[u8], out: &mut [u8], size: usize, elem_size: usize) {
570    let nbyte = elem_size * size;
571    let mut jj = 0;
572    while jj < 8 * elem_size {
573        let mut ii = 0;
574        while ii + 8 * elem_size - 1 < nbyte {
575            let mut x = trans_bit_8x8(read_u64_le(input, ii + jj));
576            for kk in 0..8 {
577                out[ii + jj / 8 + kk * elem_size] = x as u8;
578                x >>= 8;
579            }
580            ii += 8 * elem_size;
581        }
582        jj += 8;
583    }
584}
585
586/// Inverse of [`bshuf_trans_bit_elem`] — library `bshuf_untrans_bit_elem_scal`
587/// (bitshuffle_core.c:349).
588fn bshuf_untrans_bit_elem(input: &[u8], size: usize, elem_size: usize) -> Vec<u8> {
589    debug_assert_eq!(size % 8, 0);
590    let nbyte = size * elem_size;
591    let mut tmp = vec![0u8; nbyte];
592    bshuf_trans_byte_bitrow(input, &mut tmp, size, elem_size);
593    let mut out = vec![0u8; nbyte];
594    bshuf_shuffle_bit_eightelem(&tmp, &mut out, size, elem_size);
595    out
596}
597
598/// Bit-transpose and LZ4-block-compress one block, framed `[u32 nbytes_BE][lz4]`
599/// (library `bshuf_compress_lz4_block`, bitshuffle.c:32). `size` is a multiple
600/// of 8.
601fn bshuf_compress_lz4_block(
602    out: &mut Vec<u8>,
603    raw: &[u8],
604    elem_start: usize,
605    size: usize,
606    elem_size: usize,
607) {
608    let off = elem_start * elem_size;
609    let shuffled = bshuf_trans_bit_elem(&raw[off..off + size * elem_size], size, elem_size);
610    let comp = compress(&shuffled);
611    out.extend_from_slice(&(comp.len() as u32).to_be_bytes());
612    out.extend_from_slice(&comp);
613}
614
615/// Read one `[u32 nbytes_BE][lz4]` frame at `pos`, LZ4-decode and bit-untranspose
616/// it (library `bshuf_decompress_lz4_block`, bitshuffle.c:78). Returns the
617/// unshuffled block bytes and the buffer offset past the frame.
618fn bshuf_decompress_lz4_block(
619    buf: &[u8],
620    pos: usize,
621    size: usize,
622    elem_size: usize,
623) -> Option<(Vec<u8>, usize)> {
624    if pos + 4 > buf.len() {
625        return None;
626    }
627    let clen = u32::from_be_bytes(buf[pos..pos + 4].try_into().ok()?) as usize;
628    let dstart = pos + 4;
629    if dstart + clen > buf.len() {
630        return None;
631    }
632    let shuffled = decompress(&buf[dstart..dstart + clen], size * elem_size).ok()?;
633    if shuffled.len() != size * elem_size {
634        return None;
635    }
636    Some((
637        bshuf_untrans_bit_elem(&shuffled, size, elem_size),
638        dstart + clen,
639    ))
640}
641
642/// Compress an NDArray with the Bitshuffle + LZ4 (`bslz4`) codec.
643///
644/// Produces the per-block stream exactly as the bitshuffle library's
645/// `bshuf_compress_lz4` emits it (bitshuffle.c:153, blocked via
646/// `bshuf_blocked_wrap_fun`, bitshuffle_core.c:1667): every full block plus one
647/// trailing partial block (the remainder rounded down to a multiple of 8) is
648/// bit-transposed, LZ4-block-compressed and framed `[u32 nbytes_BE][lz4]`; the
649/// final `size % 8` elements are copied verbatim. There is NO global
650/// `[total][block_bytes]` header — that HDF5-chunk framing is added by the file
651/// writer (NDFileHDF5Dataset::writeFile), so this payload matches C
652/// `pArray->pData`. The original element type is recorded in the codec so
653/// decompression can rebuild the typed buffer and derive the element count.
654pub fn compress_bslz4(src: &NDArray) -> NDArray {
655    let raw = src.data.as_u8_slice();
656    let data_type = src.data.data_type();
657    let elem_size = data_type.element_size();
658    let total_elems = if elem_size > 0 {
659        raw.len() / elem_size
660    } else {
661        0
662    };
663    let block_size = bshuf_default_block_size(elem_size);
664
665    let mut out: Vec<u8> = Vec::with_capacity(raw.len() / 2 + 16);
666
667    let n_full = total_elems / block_size;
668    let mut elem = 0usize;
669    for _ in 0..n_full {
670        bshuf_compress_lz4_block(&mut out, raw, elem, block_size, elem_size);
671        elem += block_size;
672    }
673    // One trailing partial block, rounded down to a multiple of 8.
674    let mut last_block = total_elems % block_size;
675    last_block -= last_block % BSHUF_BLOCKED_MULT;
676    if last_block > 0 {
677        bshuf_compress_lz4_block(&mut out, raw, elem, last_block, elem_size);
678        elem += last_block;
679    }
680    // The final `size % 8` elements are copied raw (no shuffle, no frame).
681    if elem < total_elems {
682        out.extend_from_slice(&raw[elem * elem_size..total_elems * elem_size]);
683    }
684
685    let compressed_size = out.len();
686    let mut arr = src.clone();
687    arr.data = NDDataBuffer::U8(out);
688    arr.codec = Some(Codec {
689        name: CodecName::BSLZ4,
690        compressed_size,
691        level: 0,
692        shuffle: 0,
693        compressor: 0,
694        original_data_type: data_type,
695    });
696
697    tracing::debug!(
698        original_size = raw.len(),
699        compressed_size,
700        ratio = raw.len() as f64 / compressed_size.max(1) as f64,
701        "BSLZ4 compress"
702    );
703    arr
704}
705
706/// Decompress a Bitshuffle + LZ4 (`bslz4`) NDArray.
707///
708/// Inverse of [`compress_bslz4`], mirroring `bshuf_decompress_lz4`
709/// (bitshuffle.c:160). The uncompressed element count comes from the preserved
710/// array dims (matching C, which passes `nElements` from the NDArray, not from
711/// the payload), so the codec buffer carries no global header. Returns `None`
712/// if the codec is not BSLZ4 or the stream is malformed.
713pub fn decompress_bslz4(src: &NDArray) -> Option<NDArray> {
714    let codec = src.codec.as_ref()?;
715    if codec.name != CodecName::BSLZ4 {
716        return None;
717    }
718    let buf = src.data.as_u8_slice();
719    let original_type = original_data_type(src);
720    let elem_size = original_type.element_size();
721    if elem_size == 0 {
722        return None;
723    }
724    let total_elems: usize = src.dims.iter().map(|d| d.size).product();
725    let total_bytes = total_elems * elem_size;
726    let block_size = bshuf_default_block_size(elem_size);
727
728    let mut out: Vec<u8> = Vec::with_capacity(total_bytes);
729    let mut pos = 0usize;
730
731    let n_full = total_elems / block_size;
732    for _ in 0..n_full {
733        let (block, next) = bshuf_decompress_lz4_block(buf, pos, block_size, elem_size)?;
734        out.extend_from_slice(&block);
735        pos = next;
736    }
737    // One trailing partial block, rounded down to a multiple of 8.
738    let mut last_block = total_elems % block_size;
739    last_block -= last_block % BSHUF_BLOCKED_MULT;
740    if last_block > 0 {
741        let (block, next) = bshuf_decompress_lz4_block(buf, pos, last_block, elem_size)?;
742        out.extend_from_slice(&block);
743        pos = next;
744    }
745    // The final `size % 8` elements were copied raw.
746    let leftover_bytes = (total_elems % BSHUF_BLOCKED_MULT) * elem_size;
747    if leftover_bytes > 0 {
748        if pos + leftover_bytes > buf.len() {
749            return None;
750        }
751        out.extend_from_slice(&buf[pos..pos + leftover_bytes]);
752    }
753    if out.len() != total_bytes {
754        return None;
755    }
756
757    let buffer = buffer_from_bytes(&out, original_type)?;
758    let mut arr = src.clone();
759    arr.data = buffer;
760    arr.codec = None;
761    Some(arr)
762}
763
764/// Compress an NDArray to JPEG.
765///
766/// Mirrors C `compressJPEG` (NDPluginCodec.cpp:109-266), which decides the JPEG
767/// geometry from the dimension count (:146-169) and the *source pixel layout*
768/// from the `ColorMode` attribute (:181-227):
769/// - 2-D: grayscale, `[x, y]`.
770/// - 3-D RGB1 `[3, x, y]`: already pixel-interleaved, encoded as-is.
771/// - 3-D RGB2 `[x, 3, y]` and RGB3 `[x, y, 3]`: C walks the three colour planes
772///   (`pRed`/`pGreen`/`pBlue`, plane step `sizeX*3` for RGB2 and `sizeX` for
773///   RGB3) and re-interleaves each scanline into an RGB row before encoding.
774///   The port reaches the same pixel order through `convert_rgb_layout`, the
775///   single owner of RGB layout conversion (also used by the JPEG/TIFF/Magick
776///   file writers), so the interleave rule is not re-implemented here.
777///
778/// Both 8-bit types are accepted, as in C (`case NDInt8: case NDUInt8:`,
779/// :135-143). Returns `None` for anything C rejects: a non-8-bit type, a
780/// dimension count other than 2 or 3, or a 3-D array whose `ColorMode` is not
781/// one of the three RGB layouts.
782pub fn compress_jpeg(src: &NDArray, quality: u8) -> Result<NDArray, JpegCompressError> {
783    use ad_core_rs::color::{NDColorMode, convert_rgb_layout};
784
785    // C `:135-143` — the dataType switch comes first.
786    match src.data.data_type() {
787        NDDataType::UInt8 | NDDataType::Int8 => {}
788        _ => return Err(JpegCompressError::NotEightBit),
789    }
790
791    let info = src.info();
792
793    // C `:146-169` — the ndims switch: 2-D and 3-D have arms, anything else is
794    // "Unsupported array structure".
795    if !matches!(src.dims.len(), 2 | 3) {
796        return Err(JpegCompressError::UnsupportedArrayStructure);
797    }
798
799    // C `:181-204` — the colorMode switch: Mono/RGB1/RGB2/RGB3 have arms, and
800    // every other mode (Bayer, the three YUVs) falls to "Unknown color mode %d".
801    // `info.color_mode` is the ColorMode attribute defaulting to Mono, exactly
802    // C's `int colorMode = NDColorModeMono; if (pAttribute) getValue(...)` (:117-121).
803    match info.color_mode {
804        NDColorMode::Mono | NDColorMode::RGB1 | NDColorMode::RGB2 | NDColorMode::RGB3 => {}
805        mode => return Err(JpegCompressError::UnknownColorMode(mode as i32)),
806    }
807
808    // JPEG dimensions must fit in u16 — see `JpegCompressError::EncodeFailed`.
809    if info.x_size > u16::MAX as usize || info.y_size > u16::MAX as usize {
810        return Err(JpegCompressError::EncodeFailed);
811    }
812
813    // RGB2/RGB3 are re-interleaved to RGB1 first; every other accepted layout
814    // encodes straight out of the input buffer.
815    let (color_type, interleaved) = match (src.dims.len(), info.color_mode) {
816        (2, NDColorMode::Mono | NDColorMode::RGB1) => (jpeg_encoder::ColorType::Luma, None),
817        (3, NDColorMode::RGB1) if info.color_size == 3 => (jpeg_encoder::ColorType::Rgb, None),
818        (3, mode @ (NDColorMode::RGB2 | NDColorMode::RGB3)) if info.color_size == 3 => (
819            jpeg_encoder::ColorType::Rgb,
820            Some(
821                convert_rgb_layout(src, mode, NDColorMode::RGB1)
822                    .map_err(|_| JpegCompressError::EncodeFailed)?,
823            ),
824        ),
825        // Layouts C leaves `image_width`/`image_height` unset for, or reads out
826        // of bounds on — see `JpegCompressError::EncodeFailed`.
827        _ => return Err(JpegCompressError::EncodeFailed),
828    };
829
830    let width = info.x_size as u16;
831    let height = info.y_size as u16;
832    let pixels = interleaved
833        .as_ref()
834        .map_or_else(|| src.data.as_u8_slice(), |a| a.data.as_u8_slice());
835
836    let mut jpeg_buf = Vec::new();
837    let encoder = jpeg_encoder::Encoder::new(&mut jpeg_buf, quality);
838    if encoder.encode(pixels, width, height, color_type).is_err() {
839        return Err(JpegCompressError::EncodeFailed);
840    }
841
842    let compressed_size = jpeg_buf.len();
843    let original_size = src.data.as_u8_slice().len();
844
845    let mut arr = src.clone();
846    arr.data = NDDataBuffer::U8(jpeg_buf);
847    arr.codec = Some(Codec {
848        name: CodecName::JPEG,
849        compressed_size,
850        level: 0,
851        shuffle: 0,
852        compressor: 0,
853        // Record the source type so the codec carries the original element type
854        // uniformly (C `NDArray::dataType`, NDPluginCodec.cpp:35-36).
855        original_data_type: src.data.data_type(),
856    });
857
858    tracing::debug!(
859        original_size,
860        compressed_size,
861        ratio = original_size as f64 / compressed_size.max(1) as f64,
862        "JPEG compress (quality={})",
863        quality,
864    );
865
866    Ok(arr)
867}
868
869/// Why `compress_jpeg` refused an array, carrying the exact `errorMessage` C
870/// writes at that rejection point.
871///
872/// C's `compressJPEG` sets a *different* string at each failure and the plugin
873/// copies it verbatim into the `CodecError` PV, so the message is part of the
874/// observable contract — which means the encoder, not its caller, has to name the
875/// failure. A bare `Option` forced the caller to invent one generic text for all
876/// of them.
877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
878pub enum JpegCompressError {
879    /// C `:135-143` — only `NDInt8`/`NDUInt8` reach the encoder.
880    NotEightBit,
881    /// C `:165-169` — `ndims` is neither 2 nor 3.
882    UnsupportedArrayStructure,
883    /// C `:200-204` (and the identical guard inside the scanline loop, `:228-232`)
884    /// — the colorMode switch has no arm for this mode: Bayer and the YUVs.
885    UnknownColorMode(i32),
886    /// C `:234-238` — libjpeg would not take the data.
887    ///
888    /// The port also lands here for the arrays C hands to libjpeg's *fatal* error
889    /// handler: `jpeg_std_error` (`:115`) exits the process on error, so C has no
890    /// recovery path for dimensions past libjpeg's limit, nor for the 3-D layouts
891    /// its `else if` chain (`:155-164`) leaves `image_width`/`image_height` unset
892    /// for — a 3-D array whose ColorMode is Mono, or whose colour axis is not 3.
893    /// The port reports the failure instead of aborting the IOC, under C's own
894    /// text for "the encoder would not take this array".
895    EncodeFailed,
896}
897
898impl JpegCompressError {
899    /// The `errorMessage` C writes (NDPluginCodec.cpp:140, :166, :201, :235).
900    pub fn message(&self) -> Cow<'static, str> {
901        match self {
902            Self::NotEightBit => "JPEG only supports 8-bit data".into(),
903            Self::UnsupportedArrayStructure => "Unsupported array structure".into(),
904            // C `sprintf(errorMessage, "Unknown color mode %d", colorMode)` —
905            // NDColorMode's discriminants are C's NDColorMode_t values.
906            Self::UnknownColorMode(mode) => format!("Unknown color mode {}", mode).into(),
907            Self::EncodeFailed => "Error writing JPEG data".into(),
908        }
909    }
910}
911
912/// Decompress a JPEG-compressed NDArray.
913///
914/// Uses jpeg-decoder to decode the JPEG data back to pixel data.
915/// Reconstructs proper dimensions and color layout (mono or RGB1).
916///
917/// A decoded JPEG is always 8-bit mono or 8-bit RGB1 (C comment at
918/// NDPluginCodec.cpp:268-272), whatever the layout of the array that was
919/// compressed, so C overwrites the `ColorMode` attribute on the output
920/// (:318-322). Without that write an RGB2/RGB3 source's stale `ColorMode` would
921/// survive onto RGB1 data and every downstream `getInfo` would read the planes
922/// in the wrong order.
923///
924/// Returns `None` if the codec is not JPEG or decoding fails.
925pub fn decompress_jpeg(src: &NDArray) -> Option<NDArray> {
926    use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
927    use ad_core_rs::color::NDColorMode;
928
929    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::JPEG) {
930        return None;
931    }
932
933    let compressed = src.data.as_u8_slice();
934    let mut decoder = jpeg_decoder::Decoder::new(compressed);
935    let pixels = decoder.decode().ok()?;
936    let metadata = decoder.info()?;
937
938    let width = metadata.width as usize;
939    let height = metadata.height as usize;
940
941    let (dims, color_mode) = match metadata.pixel_format {
942        jpeg_decoder::PixelFormat::L8 => (
943            vec![NDDimension::new(width), NDDimension::new(height)],
944            NDColorMode::Mono,
945        ),
946        jpeg_decoder::PixelFormat::RGB24 => (
947            vec![
948                NDDimension::new(3),
949                NDDimension::new(width),
950                NDDimension::new(height),
951            ],
952            NDColorMode::RGB1,
953        ),
954        _ => return None,
955    };
956
957    let mut arr = src.clone();
958    arr.dims = dims;
959    arr.data = NDDataBuffer::U8(pixels);
960    arr.codec = None;
961    arr.attributes.add(NDAttribute::new_static(
962        "ColorMode",
963        "Color Mode",
964        NDAttrSource::Driver,
965        NDAttrValue::Int32(color_mode as i32),
966    ));
967
968    Some(arr)
969}
970
971/// Blosc compression settings.
972#[derive(Debug, Clone, Copy)]
973pub struct BloscConfig {
974    /// Sub-compressor: 0=BloscLZ, 1=LZ4, 2=LZ4HC, 3=Snappy, 4=Zlib, 5=Zstd
975    pub compressor: u32,
976    /// Compression level (0-9).
977    pub clevel: u32,
978    /// Shuffle mode: 0=None, 1=ByteShuffle, 2=BitShuffle.
979    pub shuffle: u32,
980}
981
982impl Default for BloscConfig {
983    fn default() -> Self {
984        Self {
985            compressor: 0,
986            // C NDPluginCodec sets the default NDCodecBloscCLevel to 5
987            // (NDPluginCodec.cpp:894); a lower default would yield different
988            // compressed bytes and NDCompressedSize than C for an unconfigured
989            // plugin.
990            clevel: 5,
991            shuffle: 0,
992        }
993    }
994}
995
996/// Compress an NDArray using Blosc via rust-hdf5's filter pipeline.
997pub fn compress_blosc(src: &NDArray, config: &BloscConfig) -> NDArray {
998    let raw = src.data.as_u8_slice();
999    let element_size = src.data.data_type().element_size();
1000
1001    // Standard H5Zblosc cd_values layout (c-blosc `blosc_filter.c`):
1002    // [filter_ver, blosc_ver, typesize, nbytes, clevel, shuffle, compcode].
1003    // The HDF5 reader keys on typesize@2, doshuffle@5 and compcode@6; placing
1004    // the sub-compressor anywhere but index 6 makes the pipeline compress with
1005    // the wrong codec (e.g. clevel 5 at slot 6 selects ZSTD instead of the
1006    // configured BloscLZ).
1007    let pipeline = FilterPipeline {
1008        filters: vec![Filter {
1009            id: FILTER_BLOSC,
1010            flags: 0,
1011            cd_values: vec![
1012                2,                   // filter version (cd_values[0])
1013                2,                   // blosc version (cd_values[1])
1014                element_size as u32, // type size (cd_values[2])
1015                raw.len() as u32,    // uncompressed chunk size (cd_values[3])
1016                config.clevel,       // compression level (cd_values[4])
1017                config.shuffle,      // shuffle (cd_values[5])
1018                config.compressor,   // sub-compressor (cd_values[6])
1019            ],
1020        }],
1021    };
1022
1023    let compressed = match apply_filters(&pipeline, raw) {
1024        Ok(data) => data,
1025        Err(_) => return src.clone(),
1026    };
1027
1028    let compressed_size = compressed.len();
1029    let original_data_type = src.data.data_type();
1030    let mut arr = src.clone();
1031    arr.data = NDDataBuffer::U8(compressed);
1032    arr.codec = Some(Codec {
1033        name: CodecName::Blosc,
1034        compressed_size,
1035        // C records the real Blosc params in the codec (NDPluginCodec.cpp:
1036        // 400-402: codec.level = clevel; shuffle; compressor), not zeros.
1037        level: config.clevel as i32,
1038        shuffle: config.shuffle as i32,
1039        compressor: config.compressor as i32,
1040        original_data_type,
1041    });
1042    arr
1043}
1044
1045/// Decompress a Blosc-compressed NDArray via rust-hdf5's filter pipeline.
1046pub fn decompress_blosc(src: &NDArray) -> Option<NDArray> {
1047    let codec = src.codec.as_ref()?;
1048    if codec.name != CodecName::Blosc {
1049        return None;
1050    }
1051
1052    let compressed = src.data.as_u8_slice();
1053    let original_type = original_data_type(src);
1054    let element_size = original_type.element_size();
1055
1056    // The blosc chunk header self-describes typesize/nbytes/flags, but the HDF5
1057    // reader takes the sub-compressor from cd_values[6] (defaulting to LZ4). An
1058    // empty cd_values therefore mis-decodes any non-LZ4 buffer, so author the
1059    // standard layout with the codec's recorded sub-compressor at index 6.
1060    let pipeline = FilterPipeline {
1061        filters: vec![Filter {
1062            id: FILTER_BLOSC,
1063            flags: 0,
1064            cd_values: vec![
1065                2,
1066                2,
1067                element_size as u32,
1068                0,
1069                codec.level as u32,
1070                codec.shuffle as u32,
1071                codec.compressor as u32,
1072            ],
1073        }],
1074    };
1075
1076    let decompressed = reverse_filters(&pipeline, compressed).ok()?;
1077
1078    let buffer = buffer_from_bytes(&decompressed, original_type)?;
1079
1080    let mut arr = src.clone();
1081    arr.data = buffer;
1082    arr.codec = None;
1083    Some(arr)
1084}
1085
1086/// Codec operation mode.
1087#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1088pub enum CodecMode {
1089    /// Compress using the specified codec. `quality` is used for JPEG (1-100).
1090    Compress { codec: CodecName, quality: u8 },
1091    /// Decompress: auto-detect codec from the array's codec field.
1092    Decompress,
1093}
1094
1095/// Pure codec processing logic.
1096///
1097/// Reports compression ratio after each operation via `compression_ratio()`.
1098#[derive(Default)]
1099struct CodecParamIndices {
1100    mode: Option<usize>,
1101    compressor: Option<usize>,
1102    comp_factor: Option<usize>,
1103    jpeg_quality: Option<usize>,
1104    blosc_compressor: Option<usize>,
1105    blosc_clevel: Option<usize>,
1106    blosc_shuffle: Option<usize>,
1107    blosc_numthreads: Option<usize>,
1108    codec_status: Option<usize>,
1109    codec_error: Option<usize>,
1110}
1111
1112/// The operator-selected codec settings plus the ratio the last frame
1113/// produced. `on_param_change` rebuilds `mode` from `jpeg_quality`, so the two
1114/// must move together.
1115struct CodecState {
1116    mode: CodecMode,
1117    compression_ratio: f64,
1118    jpeg_quality: u8,
1119    blosc_config: BloscConfig,
1120}
1121
1122pub struct CodecProcessor {
1123    state: Mutex<CodecState>,
1124    params: CodecParamIndices,
1125}
1126
1127impl CodecProcessor {
1128    pub fn new(mode: CodecMode) -> Self {
1129        let quality = match mode {
1130            CodecMode::Compress { quality, .. } => quality,
1131            _ => 85,
1132        };
1133        Self {
1134            state: Mutex::new(CodecState {
1135                mode,
1136                compression_ratio: 1.0,
1137                jpeg_quality: quality,
1138                blosc_config: BloscConfig::default(),
1139            }),
1140            params: CodecParamIndices::default(),
1141        }
1142    }
1143
1144    /// Last computed compression ratio (original_size / compressed_size).
1145    /// Returns 1.0 if no compression has been performed yet or on decompression.
1146    pub fn compression_ratio(&self) -> f64 {
1147        self.state.lock().compression_ratio
1148    }
1149}
1150
1151/// What the codec plugin decided for one input array, mirroring the exits of C
1152/// `NDPluginCodec::processCallbacks` (NDPluginCodec.cpp:649-782).
1153///
1154/// C distinguishes "the input *is* the result" (`result = pArray`, no error,
1155/// codecStatus untouched) from "the codec produced nothing" (`result = NULL` +
1156/// errorMessage, and the `finish:` block then substitutes `pArray` so the frame
1157/// still flows downstream). Both end up publishing the input array, so an
1158/// `Option<NDArray>` cannot tell them apart — collapsing them is what made an
1159/// uncompressed input to a Decompress plugin report a codec failure.
1160///
1161/// The reported severity and error string are derived from the variant
1162/// ([`CodecOutcome::status`] / [`CodecOutcome::error_message`]), so they are a
1163/// property of what happened rather than integers picked at the publish site: a
1164/// benign skip cannot be reported with a failure's severity, and no site can
1165/// invent a level C does not have.
1166enum CodecOutcome {
1167    /// C `result = pArray`, codecStatus SUCCESS: the input is the output,
1168    /// unchanged and not an error.
1169    PassThrough,
1170    /// C `result = pArray` + errorMessage + `NDCODEC_WARNING` (:671-676, and the
1171    /// same guard inside each compressor, e.g. :466-469): the operation was
1172    /// skipped, not failed — the frame flows on unchanged.
1173    Skipped(&'static str),
1174    /// C `result = <new array>`, codecStatus SUCCESS: the codec produced a new
1175    /// array.
1176    Converted(NDArray),
1177    /// C `result = NULL` + errorMessage + `NDCODEC_ERROR`: the codec failed; the
1178    /// input is republished but the error is reported.
1179    ///
1180    /// Owned, because C composes some of these with `sprintf` (e.g. "Unknown
1181    /// color mode %d", NDPluginCodec.cpp:201) — the text belongs to the codec
1182    /// that failed, not to the caller.
1183    Failed(Cow<'static, str>),
1184}
1185
1186impl CodecOutcome {
1187    /// Severity reported in `CodecStatus` (C `NDCodecStatus_t`).
1188    fn status(&self) -> CodecStatus {
1189        match self {
1190            Self::PassThrough | Self::Converted(_) => CodecStatus::Success,
1191            Self::Skipped(_) => CodecStatus::Warning,
1192            Self::Failed(_) => CodecStatus::Error,
1193        }
1194    }
1195
1196    /// Text reported in `CodecError` (C `errorMessage`, empty unless the codec
1197    /// had something to say).
1198    fn error_message(&self) -> &str {
1199        match self {
1200            Self::PassThrough | Self::Converted(_) => "",
1201            Self::Skipped(message) => message,
1202            Self::Failed(message) => message,
1203        }
1204    }
1205}
1206
1207impl NDPluginProcess for CodecProcessor {
1208    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
1209        // C reads the codec selection under the port lock and releases it
1210        // around every codec call (`NDPluginCodec.cpp:556`, `:596`), so the
1211        // compression itself never holds it. `mode`, `jpeg_quality` and
1212        // `blosc_config` are the selection; `compression_ratio` is this
1213        // frame's result and goes back under the lock at the end.
1214        let (mode, jpeg_quality, blosc_config) = {
1215            let state = self.state.lock();
1216            (state.mode, state.jpeg_quality, state.blosc_config)
1217        };
1218        let original_bytes = array.data.as_u8_slice().len();
1219
1220        // C sets NDCodecCompressor from the codec it found on the input on every
1221        // decompress branch, including the empty-codec one (NDPluginCodec.cpp:
1222        // 732-757). Compress mode never writes it — there it is the operator's
1223        // selection.
1224        let mut compressor: Option<i32> = None;
1225
1226        let outcome = match mode {
1227            // C: `algo` NONE short-circuits both the already-compressed check
1228            // (:671, gated on `algo`) and the codec switch (:680-683
1229            // `case NDCODEC_NONE: default: result = pArray`) — a pass-through,
1230            // never a failure.
1231            CodecMode::Compress {
1232                codec: CodecName::None,
1233                ..
1234            } => CodecOutcome::PassThrough,
1235            CodecMode::Compress { .. } if array.codec.is_some() => {
1236                // Already compressed — C passes the input through, but reports it
1237                // as a benign WARNING with an error string (:671-676).
1238                CodecOutcome::Skipped("Array already compressed")
1239            }
1240            CodecMode::Compress { codec, .. } => match codec {
1241                CodecName::LZ4 => CodecOutcome::Converted(compress_lz4(array)),
1242                // The encoder names its own failure (C writes a different
1243                // errorMessage at each rejection, NDPluginCodec.cpp:140, :166,
1244                // :201, :235); the caller must not invent one.
1245                CodecName::JPEG => match compress_jpeg(array, jpeg_quality) {
1246                    Ok(out) => CodecOutcome::Converted(out),
1247                    Err(e) => CodecOutcome::Failed(e.message()),
1248                },
1249                CodecName::Zlib => CodecOutcome::Converted(compress_zlib(array)),
1250                CodecName::Blosc => CodecOutcome::Converted(compress_blosc(array, &blosc_config)),
1251                CodecName::LZ4HDF5 => CodecOutcome::Converted(compress_lz4hdf5(array)),
1252                CodecName::BSLZ4 => CodecOutcome::Converted(compress_bslz4(array)),
1253                // Matched by the first arm above.
1254                CodecName::None => CodecOutcome::PassThrough,
1255            },
1256            CodecMode::Decompress => {
1257                // C keys the decompress dispatch on the input's codec *name*, so
1258                // an empty name is simply "not compressed" (`codec.empty()`,
1259                // Codec.h:37-39) — the Rust `Option` and a `CodecName::None`
1260                // inside it mean the same thing and must decide the same way.
1261                let name = array
1262                    .codec
1263                    .as_ref()
1264                    .map(|c| c.name)
1265                    .unwrap_or(CodecName::None);
1266                compressor = Some(name.ordinal());
1267                match name {
1268                    // C `NDPluginCodec.cpp:732-735` — uncompressed input: result = pArray,
1269                    // COMPRESSOR = NDCODEC_NONE, codecStatus stays SUCCESS.
1270                    CodecName::None => CodecOutcome::PassThrough,
1271                    CodecName::LZ4 => match decompress_lz4(array) {
1272                        Some(out) => CodecOutcome::Converted(out),
1273                        None => CodecOutcome::Failed("Failed to LZ4 decompress".into()),
1274                    },
1275                    CodecName::JPEG => match decompress_jpeg(array) {
1276                        Some(out) => CodecOutcome::Converted(out),
1277                        None => CodecOutcome::Failed("Error decoding JPEG".into()),
1278                    },
1279                    CodecName::Zlib => match decompress_zlib(array) {
1280                        Some(out) => CodecOutcome::Converted(out),
1281                        None => CodecOutcome::Failed("Failed to Zlib decompress".into()),
1282                    },
1283                    CodecName::Blosc => match decompress_blosc(array) {
1284                        Some(out) => CodecOutcome::Converted(out),
1285                        None => CodecOutcome::Failed("Failed to Blosc decompress".into()),
1286                    },
1287                    CodecName::LZ4HDF5 => match decompress_lz4hdf5(array) {
1288                        Some(out) => CodecOutcome::Converted(out),
1289                        None => CodecOutcome::Failed("Failed to LZ4 decompress".into()),
1290                    },
1291                    // C's decompressBSLZ4 reports "Failed to Blosc decompress"
1292                    // (NDPluginCodec.cpp:601) — a copy-paste from decompressBlosc
1293                    // (:431), but it is the text the CodecError PV shows for a
1294                    // corrupt BSLZ4 frame, so it is the contract.
1295                    CodecName::BSLZ4 => match decompress_bslz4(array) {
1296                        Some(out) => CodecOutcome::Converted(out),
1297                        None => CodecOutcome::Failed("Failed to Blosc decompress".into()),
1298                    },
1299                }
1300            }
1301        };
1302
1303        let status = outcome.status();
1304        let error = outcome.error_message().to_string();
1305
1306        // C recomputes NDCodecCompFactor only when `result != pArray`
1307        // (:726-730, :763-767); on any exit that republishes the input it stays
1308        // at 1.0.
1309        let mut compression_ratio = 1.0;
1310        let output = match outcome {
1311            CodecOutcome::Converted(out) => {
1312                let output_bytes = out.data.as_u8_slice().len();
1313                compression_ratio = match mode {
1314                    CodecMode::Compress { .. } => {
1315                        original_bytes as f64 / output_bytes.max(1) as f64
1316                    }
1317                    CodecMode::Decompress => output_bytes as f64 / original_bytes.max(1) as f64,
1318                };
1319                out
1320            }
1321            CodecOutcome::PassThrough | CodecOutcome::Skipped(_) | CodecOutcome::Failed(_) => {
1322                array.clone()
1323            }
1324        };
1325        self.state.lock().compression_ratio = compression_ratio;
1326
1327        let mut updates = Vec::new();
1328        if let Some(idx) = self.params.comp_factor {
1329            updates.push(ParamUpdate::float64(idx, compression_ratio));
1330        }
1331        if let (Some(idx), Some(value)) = (self.params.compressor, compressor) {
1332            updates.push(ParamUpdate::int32(idx, value));
1333        }
1334        if let Some(idx) = self.params.codec_status {
1335            updates.push(ParamUpdate::int32(idx, status.as_i32()));
1336        }
1337        if let Some(idx) = self.params.codec_error {
1338            updates.push(ParamUpdate::Octet {
1339                reason: idx,
1340                addr: 0,
1341                value: error,
1342            });
1343        }
1344
1345        let mut r = ProcessResult::arrays(vec![Arc::new(output)]);
1346        r.param_updates = updates;
1347        r
1348    }
1349
1350    fn plugin_type(&self) -> &str {
1351        "NDPluginCodec"
1352    }
1353
1354    /// C `NDPluginCodec` passes `compressionAware=true` to the base constructor
1355    /// (`NDPluginCodec.cpp:865-870`), unconditionally regardless of mode, so
1356    /// compressed arrays reach it for decompression. Without this override the
1357    /// runtime drop gate (`if compressed && !compression_aware`) would discard
1358    /// every compressed input before `process_array`, making `Decompress` dead.
1359    /// Returned unconditionally because the same instance can switch
1360    /// Compress↔Decompress at runtime, while this flag is read once at
1361    /// construction.
1362    fn compression_aware(&self) -> bool {
1363        true
1364    }
1365
1366    fn register_params(
1367        &mut self,
1368        base: &mut asyn_rs::port::PortDriverBase,
1369    ) -> asyn_rs::error::AsynResult<()> {
1370        use asyn_rs::param::ParamType;
1371        base.create_param("MODE", ParamType::Int32)?;
1372        base.create_param("COMPRESSOR", ParamType::Int32)?;
1373        base.create_param("COMP_FACTOR", ParamType::Float64)?;
1374        base.create_param("JPEG_QUALITY", ParamType::Int32)?;
1375        base.create_param("BLOSC_COMPRESSOR", ParamType::Int32)?;
1376        base.create_param("BLOSC_CLEVEL", ParamType::Int32)?;
1377        base.create_param("BLOSC_SHUFFLE", ParamType::Int32)?;
1378        base.create_param("BLOSC_NUMTHREADS", ParamType::Int32)?;
1379        base.create_param("CODEC_STATUS", ParamType::Int32)?;
1380        base.create_param("CODEC_ERROR", ParamType::Octet)?;
1381
1382        self.params.mode = base.find_param("MODE");
1383        self.params.compressor = base.find_param("COMPRESSOR");
1384        self.params.comp_factor = base.find_param("COMP_FACTOR");
1385        self.params.jpeg_quality = base.find_param("JPEG_QUALITY");
1386        self.params.blosc_compressor = base.find_param("BLOSC_COMPRESSOR");
1387        self.params.blosc_clevel = base.find_param("BLOSC_CLEVEL");
1388        self.params.blosc_shuffle = base.find_param("BLOSC_SHUFFLE");
1389        self.params.blosc_numthreads = base.find_param("BLOSC_NUMTHREADS");
1390        self.params.codec_status = base.find_param("CODEC_STATUS");
1391        self.params.codec_error = base.find_param("CODEC_ERROR");
1392        Ok(())
1393    }
1394
1395    fn on_param_change(
1396        &self,
1397        reason: usize,
1398        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
1399    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
1400        let mut state = self.state.lock();
1401        if Some(reason) == self.params.mode {
1402            let v = params.value.as_i32();
1403            if v == 0 {
1404                // Compress — keep current codec
1405                let codec = match state.mode {
1406                    CodecMode::Compress { codec, .. } => codec,
1407                    _ => CodecName::LZ4,
1408                };
1409                state.mode = CodecMode::Compress {
1410                    codec,
1411                    quality: state.jpeg_quality,
1412                };
1413            } else {
1414                state.mode = CodecMode::Decompress;
1415            }
1416        } else if Some(reason) == self.params.compressor {
1417            // C `NDCodecCompressor_t` (Codec.h:12-18) — the ordinal mapping lives
1418            // in `CodecName::from_ordinal`, shared with the COMPRESSOR value the
1419            // decompress path reports back.
1420            let codec = CodecName::from_ordinal(params.value.as_i32());
1421            if let CodecMode::Compress { .. } = state.mode {
1422                state.mode = CodecMode::Compress {
1423                    codec,
1424                    quality: state.jpeg_quality,
1425                };
1426            }
1427        } else if Some(reason) == self.params.jpeg_quality {
1428            state.jpeg_quality = params.value.as_i32().clamp(1, 100) as u8;
1429            if let CodecMode::Compress { codec, .. } = state.mode {
1430                state.mode = CodecMode::Compress {
1431                    codec,
1432                    quality: state.jpeg_quality,
1433                };
1434            }
1435        } else if Some(reason) == self.params.blosc_compressor {
1436            state.blosc_config.compressor = params.value.as_i32().max(0) as u32;
1437        } else if Some(reason) == self.params.blosc_clevel {
1438            state.blosc_config.clevel = params.value.as_i32().clamp(0, 9) as u32;
1439        } else if Some(reason) == self.params.blosc_shuffle {
1440            state.blosc_config.shuffle = params.value.as_i32().max(0) as u32;
1441        }
1442
1443        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
1444    }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    fn make_u8_array(width: usize, height: usize) -> NDArray {
1452        let mut arr = NDArray::new(
1453            vec![NDDimension::new(width), NDDimension::new(height)],
1454            NDDataType::UInt8,
1455        );
1456        if let NDDataBuffer::U8(ref mut v) = arr.data {
1457            for i in 0..v.len() {
1458                v[i] = (i % 256) as u8;
1459            }
1460        }
1461        arr
1462    }
1463
1464    fn make_rgb_array(width: usize, height: usize) -> NDArray {
1465        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
1466        let mut arr = NDArray::new(
1467            vec![
1468                NDDimension::new(3),
1469                NDDimension::new(width),
1470                NDDimension::new(height),
1471            ],
1472            NDDataType::UInt8,
1473        );
1474        // info() reads ColorMode for 3D arrays
1475        arr.attributes.add(NDAttribute::new_static(
1476            "ColorMode",
1477            "Color Mode",
1478            NDAttrSource::Driver,
1479            NDAttrValue::Int32(2), // RGB1
1480        ));
1481        if let NDDataBuffer::U8(ref mut v) = arr.data {
1482            for i in 0..v.len() {
1483                v[i] = (i % 256) as u8;
1484            }
1485        }
1486        arr
1487    }
1488
1489    /// Every compressor must record the original element type STRUCTURALLY in
1490    /// `codec.original_data_type` (C `NDArray::dataType`, NDPluginCodec.cpp:35-36)
1491    /// and must attach NO carrier attribute, so the attribute list a compressed
1492    /// frame carries holds only genuine driver/user attributes at every output
1493    /// boundary by construction.
1494    #[test]
1495    fn compressors_record_type_in_codec_not_an_attribute() {
1496        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt16);
1497        if let NDDataBuffer::U16(ref mut v) = arr.data {
1498            for (i, x) in v.iter_mut().enumerate() {
1499                *x = (i * 7) as u16;
1500            }
1501        }
1502        for compressed in [
1503            compress_lz4(&arr),
1504            compress_zlib(&arr),
1505            compress_lz4hdf5(&arr),
1506            compress_bslz4(&arr),
1507            compress_blosc(&arr, &BloscConfig::default()),
1508        ] {
1509            assert_eq!(
1510                compressed.codec.as_ref().unwrap().original_data_type,
1511                NDDataType::UInt16,
1512                "the original element type must travel in the codec"
1513            );
1514            assert!(
1515                compressed
1516                    .attributes
1517                    .get("CODEC_ORIGINAL_DATA_TYPE")
1518                    .is_none(),
1519                "no codec carrier attribute may be attached to a compressed frame"
1520            );
1521        }
1522    }
1523
1524    #[test]
1525    fn test_adp29_blosc_default_clevel_and_codec_params() {
1526        // C NDPluginCodec default BloscCLevel = 5 (NDPluginCodec.cpp:894); a
1527        // lower default would change the compressed bytes and NDCompressedSize.
1528        assert_eq!(
1529            BloscConfig::default().clevel,
1530            5,
1531            "default Blosc clevel must be 5 (C parity)"
1532        );
1533
1534        // C records the real level/shuffle/compressor in the codec
1535        // (NDPluginCodec.cpp:400-402), not zeros.
1536        let mut arr = NDArray::new(vec![NDDimension::new(8)], NDDataType::UInt16);
1537        if let NDDataBuffer::U16(ref mut v) = arr.data {
1538            for (i, x) in v.iter_mut().enumerate() {
1539                *x = (i * 7) as u16;
1540            }
1541        }
1542        let out = compress_blosc(&arr, &BloscConfig::default());
1543        let codec = out.codec.as_ref().expect("blosc codec metadata");
1544        // codec.level = 5 (not the old hardcoded 0) proves the real clevel is
1545        // recorded; shuffle/compressor likewise mirror the config.
1546        assert_eq!(codec.level, 5, "codec.level records the default clevel 5");
1547        assert_eq!(codec.shuffle, 0, "codec.shuffle records shuffle");
1548        assert_eq!(codec.compressor, 0, "codec.compressor records compressor");
1549    }
1550
1551    #[test]
1552    fn test_blosc_roundtrip_u16_default_compressor() {
1553        // Regression: the cd_values were mis-ordered so the sub-compressor slot
1554        // (index 6) held the clevel, selecting ZSTD instead of the configured
1555        // BloscLZ; the buffer then failed to reverse. Round-trip with the
1556        // default config (compressor 0 = BloscLZ, clevel 5) must reconstruct the
1557        // exact bytes.
1558        let mut arr = NDArray::new(
1559            vec![NDDimension::new(100), NDDimension::new(20)],
1560            NDDataType::UInt16,
1561        );
1562        if let NDDataBuffer::U16(ref mut v) = arr.data {
1563            for (i, x) in v.iter_mut().enumerate() {
1564                *x = (i * 37 % 65521) as u16;
1565            }
1566        }
1567        let original = arr.data.as_u8_slice().to_vec();
1568
1569        let compressed = compress_blosc(&arr, &BloscConfig::default());
1570        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::Blosc);
1571        assert_ne!(
1572            compressed.data.as_u8_slice(),
1573            original.as_slice(),
1574            "blosc must actually compress (not fall back to the raw clone)"
1575        );
1576
1577        let decompressed = decompress_blosc(&compressed).expect("blosc round-trip");
1578        assert!(decompressed.codec.is_none());
1579        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1580        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1581    }
1582
1583    #[test]
1584    fn test_blosc_roundtrip_u16_lz4_subcompressor() {
1585        // A non-default sub-compressor (LZ4 = 1) must round-trip too — the
1586        // recorded cd_values[6] drives the reader's sub-codec dispatch.
1587        let cfg = BloscConfig {
1588            compressor: 1,
1589            clevel: 5,
1590            shuffle: 1,
1591        };
1592        let mut arr = NDArray::new(vec![NDDimension::new(256)], NDDataType::UInt16);
1593        if let NDDataBuffer::U16(ref mut v) = arr.data {
1594            for (i, x) in v.iter_mut().enumerate() {
1595                *x = (i * 13 % 65521) as u16;
1596            }
1597        }
1598        let original = arr.data.as_u8_slice().to_vec();
1599
1600        let compressed = compress_blosc(&arr, &cfg);
1601        let codec = compressed.codec.as_ref().unwrap();
1602        assert_eq!(codec.compressor, 1, "records the LZ4 sub-compressor");
1603        assert_eq!(codec.shuffle, 1, "records byte shuffle");
1604
1605        let decompressed = decompress_blosc(&compressed).expect("blosc lz4 round-trip");
1606        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1607    }
1608
1609    // ---- LZ4 tests ----
1610
1611    #[test]
1612    fn test_lz4_roundtrip_u8() {
1613        let arr = make_u8_array(4, 4);
1614        let original_data = arr.data.as_u8_slice().to_vec();
1615
1616        let compressed = compress_lz4(&arr);
1617        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1618        // Data buffer should now be the compressed bytes
1619        assert_ne!(compressed.data.as_u8_slice(), original_data.as_slice());
1620
1621        let decompressed = decompress_lz4(&compressed).unwrap();
1622        assert!(decompressed.codec.is_none());
1623        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1624        assert_eq!(decompressed.data.as_u8_slice(), original_data.as_slice());
1625    }
1626
1627    #[test]
1628    fn test_decompress_runtime_does_not_drop_compressed_input() {
1629        // ADP-98: a Codec plugin in Decompress mode is compression-aware
1630        // (C NDPluginCodec passes compressionAware=true, NDPluginCodec.cpp:870),
1631        // so the runtime drop gate (runtime.rs:1785 `if compressed &&
1632        // !compression_aware`) must NOT discard its compressed input. Without the
1633        // compression_aware() override the compressed array is dropped before
1634        // process_array runs and the entire Decompress path is dead.
1635        use ad_core_rs::plugin::channel::{NDArrayOutput, ndarray_channel};
1636        use ad_core_rs::plugin::runtime::create_plugin_runtime_with_output;
1637        use ad_core_rs::plugin::wiring::WiringRegistry;
1638        use std::sync::atomic::Ordering;
1639
1640        // A genuinely-compressed input array (codec = LZ4).
1641        let mut raw = make_u8_array(4, 4);
1642        raw.unique_id = 1;
1643        let original_data = raw.data.as_u8_slice().to_vec();
1644        let compressed = compress_lz4(&raw);
1645        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1646        assert_eq!(compressed.unique_id, 1);
1647
1648        // Sentinel uncompressed array: even if the compressed one is dropped, this
1649        // reaches downstream, so a wrong first unique_id pinpoints the drop (no
1650        // reliance on a timeout).
1651        let mut sentinel = make_u8_array(4, 4);
1652        sentinel.unique_id = 2;
1653
1654        let pool = Arc::new(NDArrayPool::new(1_000_000));
1655        let (ds_sender, mut ds_rx) = ndarray_channel("DS", 10);
1656        let mut output = NDArrayOutput::new();
1657        output.add(ds_sender);
1658        let (handle, _jh) = create_plugin_runtime_with_output(
1659            "CODEC_DECOMP",
1660            CodecProcessor::new(CodecMode::Decompress),
1661            pool,
1662            10,
1663            output,
1664            "",
1665            Arc::new(WiringRegistry::new()),
1666        );
1667        let dropped = handle.array_sender().dropped_arrays_counter().clone();
1668        handle
1669            .port_runtime()
1670            .port_handle()
1671            .write_int32_blocking(handle.plugin_params.enable_callbacks, 0, 1)
1672            .unwrap();
1673        // Fence: the write only queues the enable for the data thread.
1674        assert!(
1675            handle.wait_params_applied(std::time::Duration::from_secs(10)),
1676            "data thread did not apply EnableCallbacks"
1677        );
1678
1679        let rt = tokio::runtime::Builder::new_current_thread()
1680            .enable_all()
1681            .build()
1682            .unwrap();
1683        rt.block_on(handle.array_sender().publish(Arc::new(compressed)));
1684        rt.block_on(handle.array_sender().publish(Arc::new(sentinel)));
1685
1686        let first = ds_rx.blocking_recv().expect("downstream array");
1687        assert_eq!(
1688            first.unique_id, 1,
1689            "compressed input must be decompressed and delivered, not dropped"
1690        );
1691        assert!(
1692            first.codec.is_none(),
1693            "delivered array must be decompressed (codec cleared)"
1694        );
1695        assert_eq!(first.data.as_u8_slice(), original_data.as_slice());
1696        assert_eq!(
1697            dropped.load(Ordering::Acquire),
1698            0,
1699            "compression-aware Codec must not count its compressed input as dropped"
1700        );
1701    }
1702
1703    #[test]
1704    fn test_lz4_roundtrip_u16() {
1705        let mut arr = NDArray::new(
1706            vec![NDDimension::new(8), NDDimension::new(8)],
1707            NDDataType::UInt16,
1708        );
1709        if let NDDataBuffer::U16(ref mut v) = arr.data {
1710            for i in 0..v.len() {
1711                v[i] = (i * 100) as u16;
1712            }
1713        }
1714        let original_bytes = arr.data.as_u8_slice().to_vec();
1715
1716        let compressed = compress_lz4(&arr);
1717        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
1718        // The original data type is recorded structurally in the codec.
1719        assert_eq!(
1720            compressed.codec.as_ref().unwrap().original_data_type,
1721            NDDataType::UInt16
1722        );
1723        // No carrier attribute leaks onto the array.
1724        assert!(
1725            compressed
1726                .attributes
1727                .get("CODEC_ORIGINAL_DATA_TYPE")
1728                .is_none()
1729        );
1730
1731        let decompressed = decompress_lz4(&compressed).unwrap();
1732        assert!(decompressed.codec.is_none());
1733        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1734        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
1735    }
1736
1737    #[test]
1738    fn test_lz4_roundtrip_f64() {
1739        let mut arr = NDArray::new(vec![NDDimension::new(16)], NDDataType::Float64);
1740        if let NDDataBuffer::F64(ref mut v) = arr.data {
1741            for i in 0..v.len() {
1742                v[i] = i as f64 * 1.5;
1743            }
1744        }
1745        let original_bytes = arr.data.as_u8_slice().to_vec();
1746
1747        let compressed = compress_lz4(&arr);
1748        let decompressed = decompress_lz4(&compressed).unwrap();
1749        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1750        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
1751    }
1752
1753    #[test]
1754    fn test_lz4_compresses_repetitive_data() {
1755        // Highly repetitive data should compress well
1756        let mut arr = NDArray::new(
1757            vec![NDDimension::new(256), NDDimension::new(256)],
1758            NDDataType::UInt8,
1759        );
1760        // All zeros = very compressible
1761        if let NDDataBuffer::U8(ref mut v) = arr.data {
1762            for x in v.iter_mut() {
1763                *x = 0;
1764            }
1765        }
1766        let original_size = arr.data.as_u8_slice().len();
1767
1768        let compressed = compress_lz4(&arr);
1769        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
1770        assert!(
1771            compressed_size < original_size,
1772            "compressed ({}) should be smaller than original ({})",
1773            compressed_size,
1774            original_size,
1775        );
1776    }
1777
1778    #[test]
1779    fn test_lz4_preserves_metadata() {
1780        let mut arr = make_u8_array(4, 4);
1781        arr.unique_id = 42;
1782
1783        let compressed = compress_lz4(&arr);
1784        assert_eq!(compressed.unique_id, 42);
1785        assert_eq!(compressed.dims.len(), 2);
1786        assert_eq!(compressed.dims[0].size, 4);
1787        assert_eq!(compressed.dims[1].size, 4);
1788    }
1789
1790    // ---- Bitshuffle / LZ4 (bslz4) tests ----
1791
1792    #[test]
1793    fn test_bitshuffle_block_transpose_roundtrip() {
1794        // The canonical bit transpose must be its own paired inverse for a
1795        // block whose element count is a multiple of 8, across element sizes.
1796        for &(n, elem_size) in &[(16usize, 4usize), (8, 2), (256, 8), (128, 1)] {
1797            let input: Vec<u8> = (0..n * elem_size).map(|i| (i * 7 + 3) as u8).collect();
1798            let shuffled = bshuf_trans_bit_elem(&input, n, elem_size);
1799            assert_eq!(shuffled.len(), input.len());
1800            let restored = bshuf_untrans_bit_elem(&shuffled, n, elem_size);
1801            assert_eq!(restored, input, "elem_size {elem_size}, n {n}");
1802        }
1803    }
1804
1805    #[test]
1806    fn test_bitshuffle_matches_c_reference_vector() {
1807        // Locks the on-disk byte format to the canonical bitshuffle library
1808        // (the one h5py / libhdf5 / C areaDetector use). The expected vector was
1809        // produced by compiling hdf5_plugins/BSHUF/src/bitshuffle_core.c
1810        // (scalar path) and running `bshuf_bitshuffle(in, out, 16, 2, 0)` on the
1811        // u16 ramp 0..15: bit-row 0 (LSB of each elem) packs elem k -> output
1812        // bit k (little-endian element order), giving 0xAA/0xCC/0xF0 for the
1813        // varying low nibble and 0xFF where bit 3 separates elems 8..15.
1814        let input: Vec<u8> = (0..16u16).flat_map(|v| v.to_le_bytes()).collect();
1815        let shuffled = bshuf_trans_bit_elem(&input, 16, 2);
1816        let mut expected = vec![0u8; 32];
1817        expected[..8].copy_from_slice(&[170, 170, 204, 204, 240, 240, 0, 255]);
1818        assert_eq!(
1819            shuffled, expected,
1820            "canonical bitshuffle transpose must match the C library bytes"
1821        );
1822    }
1823
1824    #[test]
1825    fn test_bslz4_roundtrip_u8() {
1826        let mut arr = NDArray::new(
1827            vec![NDDimension::new(64), NDDimension::new(64)],
1828            NDDataType::UInt8,
1829        );
1830        if let NDDataBuffer::U8(ref mut v) = arr.data {
1831            for (i, x) in v.iter_mut().enumerate() {
1832                *x = (i % 251) as u8;
1833            }
1834        }
1835        let original = arr.data.as_u8_slice().to_vec();
1836
1837        let compressed = compress_bslz4(&arr);
1838        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::BSLZ4);
1839        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
1840
1841        let decompressed = decompress_bslz4(&compressed).unwrap();
1842        assert!(decompressed.codec.is_none());
1843        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
1844        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1845    }
1846
1847    #[test]
1848    fn test_bslz4_roundtrip_u16() {
1849        let mut arr = NDArray::new(
1850            vec![NDDimension::new(100), NDDimension::new(20)],
1851            NDDataType::UInt16,
1852        );
1853        if let NDDataBuffer::U16(ref mut v) = arr.data {
1854            for (i, x) in v.iter_mut().enumerate() {
1855                *x = (i * 37 % 65521) as u16;
1856            }
1857        }
1858        let original = arr.data.as_u8_slice().to_vec();
1859
1860        let compressed = compress_bslz4(&arr);
1861        assert_eq!(
1862            compressed.codec.as_ref().unwrap().original_data_type,
1863            NDDataType::UInt16
1864        );
1865        let decompressed = decompress_bslz4(&compressed).unwrap();
1866        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
1867        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1868    }
1869
1870    #[test]
1871    fn test_bslz4_roundtrip_f64_with_negatives() {
1872        let mut arr = NDArray::new(vec![NDDimension::new(73)], NDDataType::Float64);
1873        if let NDDataBuffer::F64(ref mut v) = arr.data {
1874            for (i, x) in v.iter_mut().enumerate() {
1875                *x = (i as f64 - 36.0) * 2.5;
1876            }
1877        }
1878        let original = arr.data.as_u8_slice().to_vec();
1879
1880        let compressed = compress_bslz4(&arr);
1881        let decompressed = decompress_bslz4(&compressed).unwrap();
1882        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
1883        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1884    }
1885
1886    #[test]
1887    fn test_bslz4_roundtrip_multi_block() {
1888        // A buffer larger than the default block size exercises the
1889        // per-block container framing and a trailing partial block.
1890        let elem_size = 4usize;
1891        let block = bshuf_default_block_size(elem_size);
1892        // 2.5 blocks worth of i32 elements.
1893        let count = block * 2 + block / 2 + 3;
1894        let mut arr = NDArray::new(vec![NDDimension::new(count)], NDDataType::Int32);
1895        if let NDDataBuffer::I32(ref mut v) = arr.data {
1896            for (i, x) in v.iter_mut().enumerate() {
1897                *x = (i as i32).wrapping_mul(2_654_435_761u32 as i32);
1898            }
1899        }
1900        let original = arr.data.as_u8_slice().to_vec();
1901
1902        let compressed = compress_bslz4(&arr);
1903        let decompressed = decompress_bslz4(&compressed).unwrap();
1904        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
1905    }
1906
1907    #[test]
1908    fn test_bslz4_compresses_repetitive_data() {
1909        // Bitshuffle makes near-constant data extremely compressible.
1910        let arr = NDArray::new(
1911            vec![NDDimension::new(256), NDDimension::new(256)],
1912            NDDataType::UInt16,
1913        );
1914        let original_size = arr.data.as_u8_slice().len();
1915        let compressed = compress_bslz4(&arr);
1916        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
1917        assert!(
1918            compressed_size < original_size,
1919            "bslz4 compressed ({compressed_size}) should be < original ({original_size})"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_r9_71_corrupt_bslz4_reports_cs_blosc_text() {
1925        // R9-71. C's decompressBSLZ4 reports "Failed to Blosc decompress"
1926        // (NDPluginCodec.cpp:601) — a copy-paste from decompressBlosc (:431), but
1927        // it is what the CodecError PV shows for a corrupt BSLZ4 frame, so the
1928        // port must emit it verbatim rather than the "corrected" BSLZ4 wording.
1929        use ad_core_rs::plugin::runtime::ParamUpdate;
1930
1931        let mut arr = NDArray::new(
1932            vec![NDDimension::new(32), NDDimension::new(32)],
1933            NDDataType::UInt16,
1934        );
1935        if let NDDataBuffer::U16(ref mut v) = arr.data {
1936            for (i, x) in v.iter_mut().enumerate() {
1937                *x = (i * 11) as u16;
1938            }
1939        }
1940        let pool = NDArrayPool::new(10_000_000);
1941
1942        // A genuine BSLZ4 frame, then corrupt the compressed payload.
1943        let mut compressed = compress_bslz4(&arr);
1944        if let NDDataBuffer::U8(ref mut v) = compressed.data {
1945            for b in v.iter_mut() {
1946                *b = 0xFF;
1947            }
1948        }
1949        assert!(
1950            decompress_bslz4(&compressed).is_none(),
1951            "the corrupted frame must fail to decompress"
1952        );
1953
1954        let mut decomp = CodecProcessor::new(CodecMode::Decompress);
1955        decomp.params.codec_error = Some(13);
1956        let result = decomp.process_array(&compressed, &pool);
1957        let text = result
1958            .param_updates
1959            .iter()
1960            .find_map(|u| match u {
1961                ParamUpdate::Octet {
1962                    reason: 13, value, ..
1963                } => Some(value.clone()),
1964                _ => None,
1965            })
1966            .expect("CodecError posted");
1967        assert_eq!(text, "Failed to Blosc decompress");
1968    }
1969
1970    #[test]
1971    fn test_bslz4_via_processor() {
1972        // The CodecProcessor must round-trip through the BSLZ4 codec.
1973        let mut arr = NDArray::new(
1974            vec![NDDimension::new(32), NDDimension::new(32)],
1975            NDDataType::UInt16,
1976        );
1977        if let NDDataBuffer::U16(ref mut v) = arr.data {
1978            for (i, x) in v.iter_mut().enumerate() {
1979                *x = (i * 11) as u16;
1980            }
1981        }
1982        let original = arr.data.as_u8_slice().to_vec();
1983        let pool = NDArrayPool::new(10_000_000);
1984
1985        let comp = CodecProcessor::new(CodecMode::Compress {
1986            codec: CodecName::BSLZ4,
1987            quality: 0,
1988        });
1989        let compressed = comp.process_array(&arr, &pool);
1990        let compressed_arr = &compressed.output_arrays[0];
1991        assert_eq!(
1992            compressed_arr.codec.as_ref().unwrap().name,
1993            CodecName::BSLZ4
1994        );
1995
1996        let decomp = CodecProcessor::new(CodecMode::Decompress);
1997        let result = decomp.process_array(compressed_arr, &pool);
1998        assert_eq!(
1999            result.output_arrays[0].data.as_u8_slice(),
2000            original.as_slice()
2001        );
2002    }
2003
2004    // ---- JPEG tests ----
2005
2006    #[test]
2007    fn test_jpeg_compress_mono() {
2008        let arr = make_u8_array(16, 16);
2009        let compressed = compress_jpeg(&arr, 90).unwrap();
2010        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
2011        // Compressed data should be valid JPEG (starts with SOI marker)
2012        let data = compressed.data.as_u8_slice();
2013        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
2014    }
2015
2016    #[test]
2017    fn test_jpeg_compress_rgb() {
2018        let arr = make_rgb_array(16, 16);
2019        let compressed = compress_jpeg(&arr, 90).unwrap();
2020        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
2021        let data = compressed.data.as_u8_slice();
2022        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
2023    }
2024
2025    #[test]
2026    fn test_jpeg_roundtrip_mono() {
2027        let arr = make_u8_array(16, 16);
2028        let compressed = compress_jpeg(&arr, 100).unwrap();
2029        let decompressed = decompress_jpeg(&compressed).unwrap();
2030        assert!(decompressed.codec.is_none());
2031        assert_eq!(decompressed.dims.len(), 2);
2032        assert_eq!(decompressed.dims[0].size, 16); // width
2033        assert_eq!(decompressed.dims[1].size, 16); // height
2034        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
2035        // JPEG is lossy, so data won't be identical, but dimensions match
2036        assert_eq!(decompressed.data.len(), 16 * 16);
2037    }
2038
2039    #[test]
2040    fn test_jpeg_roundtrip_rgb() {
2041        let arr = make_rgb_array(16, 16);
2042        let compressed = compress_jpeg(&arr, 100).unwrap();
2043        let decompressed = decompress_jpeg(&compressed).unwrap();
2044        assert!(decompressed.codec.is_none());
2045        assert_eq!(decompressed.dims.len(), 3);
2046        assert_eq!(decompressed.dims[0].size, 3); // color
2047        assert_eq!(decompressed.dims[1].size, 16); // width
2048        assert_eq!(decompressed.dims[2].size, 16); // height
2049        assert_eq!(decompressed.data.len(), 3 * 16 * 16);
2050    }
2051
2052    // ---- R8-62: JPEG compression of RGB2 / RGB3 ----
2053
2054    /// The same RGB image in one of the three AD layouts. `pixel(x, y, c)` is
2055    /// deterministic so the three arrays hold identical pixels, only ordered
2056    /// differently.
2057    fn make_rgb_layout(mode: ad_core_rs::color::NDColorMode, w: usize, h: usize) -> NDArray {
2058        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
2059        use ad_core_rs::color::NDColorMode;
2060
2061        let pixel = |x: usize, y: usize, c: usize| ((x * 7 + y * 13 + c * 61) % 256) as u8;
2062        let dims = match mode {
2063            NDColorMode::RGB1 => vec![3, w, h],
2064            NDColorMode::RGB2 => vec![w, 3, h],
2065            NDColorMode::RGB3 => vec![w, h, 3],
2066            other => panic!("not an RGB layout: {other:?}"),
2067        };
2068        let mut arr = NDArray::new(
2069            dims.into_iter().map(NDDimension::new).collect(),
2070            NDDataType::UInt8,
2071        );
2072        arr.attributes.add(NDAttribute::new_static(
2073            "ColorMode",
2074            "Color Mode",
2075            NDAttrSource::Driver,
2076            NDAttrValue::Int32(mode as i32),
2077        ));
2078        if let NDDataBuffer::U8(ref mut v) = arr.data {
2079            for y in 0..h {
2080                for x in 0..w {
2081                    for c in 0..3 {
2082                        let idx = match mode {
2083                            NDColorMode::RGB1 => c + x * 3 + y * w * 3,
2084                            NDColorMode::RGB2 => x + c * w + y * w * 3,
2085                            NDColorMode::RGB3 => x + y * w + c * w * h,
2086                            _ => unreachable!(),
2087                        };
2088                        v[idx] = pixel(x, y, c);
2089                    }
2090                }
2091            }
2092        }
2093        arr
2094    }
2095
2096    #[test]
2097    fn test_r8_62_jpeg_compresses_rgb2_and_rgb3_as_reinterleaved_rgb() {
2098        // C compressJPEG walks the RGB2 (plane step sizeX*3) and RGB3 (plane step
2099        // sizeX) colour planes and re-interleaves each scanline before encoding
2100        // (NDPluginCodec.cpp:186-227), producing exactly the JPEG of the
2101        // equivalent RGB1 image. The port rejected both layouts outright.
2102        use ad_core_rs::color::NDColorMode;
2103
2104        let rgb1 = make_rgb_layout(NDColorMode::RGB1, 16, 8);
2105        let reference = compress_jpeg(&rgb1, 90).expect("RGB1 must compress");
2106
2107        for mode in [NDColorMode::RGB2, NDColorMode::RGB3] {
2108            let src = make_rgb_layout(mode, 16, 8);
2109            let out = compress_jpeg(&src, 90)
2110                .unwrap_or_else(|e| panic!("{mode:?} must compress, C encodes it: {e:?}"));
2111            assert_eq!(out.codec.as_ref().unwrap().name, CodecName::JPEG);
2112            assert_eq!(&out.data.as_u8_slice()[0..2], &[0xFF, 0xD8], "SOI marker");
2113            assert_eq!(
2114                out.data.as_u8_slice(),
2115                reference.data.as_u8_slice(),
2116                "{mode:?} must encode the same pixels as the RGB1 image — a wrong \
2117                 (or missing) scanline re-interleave changes the JPEG bytes"
2118            );
2119            // C's allocArray copies the input dimensions onto the output.
2120            assert_eq!(out.dims.len(), 3);
2121        }
2122    }
2123
2124    #[test]
2125    fn test_r8_62_decompressed_jpeg_reports_rgb1_colormode() {
2126        // A decoded JPEG is always mono or RGB1 (C's comment at :268-272), so C overwrites the
2127        // ColorMode attribute on the output (:318-322). An RGB2 source's stale
2128        // ColorMode=3 on RGB1 data would make every downstream getInfo read the
2129        // planes in the wrong order.
2130        use ad_core_rs::color::NDColorMode;
2131
2132        let src = make_rgb_layout(NDColorMode::RGB2, 16, 8);
2133        let compressed = compress_jpeg(&src, 90).expect("rgb2 jpeg");
2134        assert_eq!(
2135            compressed
2136                .attributes
2137                .get("ColorMode")
2138                .unwrap()
2139                .value
2140                .as_i64(),
2141            Some(NDColorMode::RGB2 as i64),
2142            "the compressed frame keeps the source ColorMode"
2143        );
2144
2145        let out = decompress_jpeg(&compressed).expect("jpeg decode");
2146        assert_eq!(
2147            out.attributes.get("ColorMode").unwrap().value.as_i64(),
2148            Some(NDColorMode::RGB1 as i64),
2149            "decompressed JPEG must be reported as RGB1"
2150        );
2151        assert_eq!(out.dims[0].size, 3);
2152        assert_eq!(out.dims[1].size, 16);
2153        assert_eq!(out.dims[2].size, 8);
2154        assert_eq!(out.info().color_mode, NDColorMode::RGB1);
2155
2156        // Mono round-trip reports Mono, not a stale colour mode.
2157        let mono = decompress_jpeg(&compress_jpeg(&make_u8_array(16, 16), 90).unwrap()).unwrap();
2158        assert_eq!(
2159            mono.attributes.get("ColorMode").unwrap().value.as_i64(),
2160            Some(NDColorMode::Mono as i64)
2161        );
2162    }
2163
2164    #[test]
2165    fn test_r8_62_jpeg_accepts_int8_like_c() {
2166        // C accepts both 8-bit types (`case NDInt8: case NDUInt8:`, :135-143) and
2167        // encodes the raw bytes; only wider types are rejected ("JPEG only
2168        // supports 8-bit data").
2169        let mut arr = NDArray::new(
2170            vec![NDDimension::new(8), NDDimension::new(8)],
2171            NDDataType::Int8,
2172        );
2173        if let NDDataBuffer::I8(ref mut v) = arr.data {
2174            for (i, x) in v.iter_mut().enumerate() {
2175                *x = (i as i32 - 32) as i8;
2176            }
2177        }
2178        let out = compress_jpeg(&arr, 90).expect("Int8 must compress");
2179        assert_eq!(&out.data.as_u8_slice()[0..2], &[0xFF, 0xD8]);
2180        assert_eq!(
2181            out.codec.as_ref().unwrap().original_data_type,
2182            NDDataType::Int8
2183        );
2184    }
2185
2186    #[test]
2187    fn test_r8_62_jpeg_rejects_3d_without_an_rgb_colormode() {
2188        // A 3-D array whose ColorMode is Mono (the default when the attribute is
2189        // absent, C :117-121) is not JPEG-encodable: C's `else if` chain (:155-164)
2190        // matches none of RGB1/2/3, so image_width/image_height are never set, and
2191        // the empty image reaches libjpeg's FATAL handler (jpeg_std_error, :115 —
2192        // its error_exit calls exit()). It is NOT the "Unknown color mode" branch,
2193        // which this test used to claim: C's colorMode switch does have a
2194        // `case NDColorModeMono` arm (:182). The port refuses instead of aborting,
2195        // under C's "Error writing JPEG data" (:235).
2196        let arr = NDArray::new(
2197            vec![
2198                NDDimension::new(3),
2199                NDDimension::new(8),
2200                NDDimension::new(8),
2201            ],
2202            NDDataType::UInt8,
2203        );
2204        assert_eq!(
2205            compress_jpeg(&arr, 90).unwrap_err(),
2206            JpegCompressError::EncodeFailed,
2207            "3-D Mono (no ColorMode attribute) is not a JPEG-encodable layout in C"
2208        );
2209    }
2210
2211    #[test]
2212    fn test_jpeg_rejects_non_u8() {
2213        // R8-74: C `:139-142` — "JPEG only supports 8-bit data".
2214        let arr = NDArray::new(
2215            vec![NDDimension::new(8), NDDimension::new(8)],
2216            NDDataType::UInt16,
2217        );
2218        let err = compress_jpeg(&arr, 90).unwrap_err();
2219        assert_eq!(err, JpegCompressError::NotEightBit);
2220        assert_eq!(err.message(), "JPEG only supports 8-bit data");
2221    }
2222
2223    #[test]
2224    fn test_jpeg_rejects_1d() {
2225        // R8-74: C `:165-168` — "Unsupported array structure" for ndims ∉ {2,3}.
2226        let arr = NDArray::new(vec![NDDimension::new(64)], NDDataType::UInt8);
2227        let err = compress_jpeg(&arr, 90).unwrap_err();
2228        assert_eq!(err, JpegCompressError::UnsupportedArrayStructure);
2229        assert_eq!(err.message(), "Unsupported array structure");
2230    }
2231
2232    #[test]
2233    fn test_r8_74_jpeg_compress_failures_carry_the_c_error_texts() {
2234        // R8-74. C writes a *different* errorMessage at each rejection point and
2235        // the plugin copies it verbatim into the CodecError PV, so each text is
2236        // part of the contract. The port reported one generic "JPEG compression
2237        // failed" for all of them, because compress_jpeg returned a bare Option and
2238        // the caller had to invent the text.
2239        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
2240        use ad_core_rs::color::NDColorMode;
2241
2242        // C :140 — dataType is not 8-bit.
2243        let arr = NDArray::new(
2244            vec![NDDimension::new(8), NDDimension::new(8)],
2245            NDDataType::Float32,
2246        );
2247        assert_eq!(
2248            compress_jpeg(&arr, 90).unwrap_err().message(),
2249            "JPEG only supports 8-bit data"
2250        );
2251
2252        // C :166 — ndims is neither 2 nor 3.
2253        let arr = NDArray::new(
2254            vec![
2255                NDDimension::new(2),
2256                NDDimension::new(2),
2257                NDDimension::new(2),
2258                NDDimension::new(2),
2259            ],
2260            NDDataType::UInt8,
2261        );
2262        assert_eq!(
2263            compress_jpeg(&arr, 90).unwrap_err().message(),
2264            "Unsupported array structure"
2265        );
2266
2267        // C :201 — a colorMode with no arm in the switch. Bayer is 1, YUV444 is 5;
2268        // NDColorMode's discriminants are C's NDColorMode_t values, so the `%d`
2269        // must print those numbers.
2270        for (mode, text) in [
2271            (NDColorMode::Bayer, "Unknown color mode 1"),
2272            (NDColorMode::YUV444, "Unknown color mode 5"),
2273            (NDColorMode::YUV411, "Unknown color mode 7"),
2274        ] {
2275            let mut arr = NDArray::new(
2276                vec![NDDimension::new(8), NDDimension::new(8)],
2277                NDDataType::UInt8,
2278            );
2279            arr.attributes.add(NDAttribute::new_static(
2280                "ColorMode",
2281                "",
2282                NDAttrSource::Driver,
2283                NDAttrValue::Int32(mode as i32),
2284            ));
2285            assert_eq!(compress_jpeg(&arr, 90).unwrap_err().message(), text);
2286        }
2287    }
2288
2289    #[test]
2290    fn test_r8_74_codec_error_pv_carries_the_jpeg_text() {
2291        // The typed error must reach the CodecError PV, not just the return value:
2292        // C copies `errorMessage` into it verbatim.
2293        use ad_core_rs::plugin::runtime::ParamUpdate;
2294
2295        let mut proc = CodecProcessor::new(CodecMode::Compress {
2296            codec: CodecName::JPEG,
2297            quality: 90,
2298        });
2299        proc.params.codec_error = Some(13);
2300        let arr = NDArray::new(
2301            vec![NDDimension::new(8), NDDimension::new(8)],
2302            NDDataType::UInt16,
2303        );
2304        let result = proc.process_array(&arr, &NDArrayPool::new(0));
2305        let text = result
2306            .param_updates
2307            .iter()
2308            .find_map(|u| match u {
2309                ParamUpdate::Octet {
2310                    reason: 13, value, ..
2311                } => Some(value.clone()),
2312                _ => None,
2313            })
2314            .expect("CodecError posted");
2315        assert_eq!(text, "JPEG only supports 8-bit data");
2316    }
2317
2318    #[test]
2319    fn test_jpeg_quality_affects_size() {
2320        let arr = make_u8_array(64, 64);
2321        let high = compress_jpeg(&arr, 95).unwrap();
2322        let low = compress_jpeg(&arr, 10).unwrap();
2323        let high_size = high.codec.as_ref().unwrap().compressed_size;
2324        let low_size = low.codec.as_ref().unwrap().compressed_size;
2325        assert!(
2326            high_size > low_size,
2327            "high quality ({}) should produce larger output than low quality ({})",
2328            high_size,
2329            low_size,
2330        );
2331    }
2332
2333    // ---- Zlib tests ----
2334
2335    #[test]
2336    fn test_zlib_roundtrip_u8() {
2337        let arr = make_u8_array(8, 8);
2338        let original = arr.data.as_u8_slice().to_vec();
2339
2340        let compressed = compress_zlib(&arr);
2341        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::Zlib);
2342        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
2343
2344        let decompressed = decompress_zlib(&compressed).unwrap();
2345        assert!(decompressed.codec.is_none());
2346        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
2347        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2348    }
2349
2350    #[test]
2351    fn test_zlib_roundtrip_u16() {
2352        let mut arr = NDArray::new(
2353            vec![NDDimension::new(16), NDDimension::new(16)],
2354            NDDataType::UInt16,
2355        );
2356        if let NDDataBuffer::U16(ref mut v) = arr.data {
2357            for (i, x) in v.iter_mut().enumerate() {
2358                *x = (i * 257 % 65521) as u16;
2359            }
2360        }
2361        let original = arr.data.as_u8_slice().to_vec();
2362
2363        let compressed = compress_zlib(&arr);
2364        assert_eq!(
2365            compressed.codec.as_ref().unwrap().original_data_type,
2366            NDDataType::UInt16
2367        );
2368
2369        let decompressed = decompress_zlib(&compressed).unwrap();
2370        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
2371        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2372    }
2373
2374    #[test]
2375    fn test_zlib_roundtrip_f64_with_negatives() {
2376        let mut arr = NDArray::new(vec![NDDimension::new(64)], NDDataType::Float64);
2377        if let NDDataBuffer::F64(ref mut v) = arr.data {
2378            for (i, x) in v.iter_mut().enumerate() {
2379                *x = (i as f64 - 32.0) * 3.25;
2380            }
2381        }
2382        let original = arr.data.as_u8_slice().to_vec();
2383
2384        let compressed = compress_zlib(&arr);
2385        let decompressed = decompress_zlib(&compressed).unwrap();
2386        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
2387        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2388    }
2389
2390    #[test]
2391    fn test_zlib_compresses_repetitive_data() {
2392        let arr = NDArray::new(
2393            vec![NDDimension::new(256), NDDimension::new(256)],
2394            NDDataType::UInt8,
2395        );
2396        let original_size = arr.data.as_u8_slice().len();
2397        let compressed = compress_zlib(&arr);
2398        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
2399        assert!(
2400            compressed_size < original_size,
2401            "zlib compressed ({compressed_size}) should be < original ({original_size})"
2402        );
2403    }
2404
2405    #[test]
2406    fn test_zlib_via_processor() {
2407        let mut arr = NDArray::new(
2408            vec![NDDimension::new(32), NDDimension::new(32)],
2409            NDDataType::UInt16,
2410        );
2411        if let NDDataBuffer::U16(ref mut v) = arr.data {
2412            for (i, x) in v.iter_mut().enumerate() {
2413                *x = (i * 13) as u16;
2414            }
2415        }
2416        let original = arr.data.as_u8_slice().to_vec();
2417        let pool = NDArrayPool::new(10_000_000);
2418
2419        let comp = CodecProcessor::new(CodecMode::Compress {
2420            codec: CodecName::Zlib,
2421            quality: 0,
2422        });
2423        let compressed = comp.process_array(&arr, &pool);
2424        let compressed_arr = &compressed.output_arrays[0];
2425        assert_eq!(compressed_arr.codec.as_ref().unwrap().name, CodecName::Zlib);
2426
2427        let decomp = CodecProcessor::new(CodecMode::Decompress);
2428        let result = decomp.process_array(compressed_arr, &pool);
2429        assert_eq!(
2430            result.output_arrays[0].data.as_u8_slice(),
2431            original.as_slice()
2432        );
2433    }
2434
2435    // ---- LZ4HDF5 tests ----
2436
2437    #[test]
2438    fn test_lz4hdf5_roundtrip_u8() {
2439        let mut arr = NDArray::new(
2440            vec![NDDimension::new(64), NDDimension::new(64)],
2441            NDDataType::UInt8,
2442        );
2443        if let NDDataBuffer::U8(ref mut v) = arr.data {
2444            for (i, x) in v.iter_mut().enumerate() {
2445                *x = (i % 251) as u8;
2446            }
2447        }
2448        let original = arr.data.as_u8_slice().to_vec();
2449
2450        let compressed = compress_lz4hdf5(&arr);
2451        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4HDF5);
2452        assert_ne!(compressed.data.as_u8_slice(), original.as_slice());
2453
2454        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
2455        assert!(decompressed.codec.is_none());
2456        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
2457        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2458    }
2459
2460    #[test]
2461    fn test_lz4hdf5_roundtrip_u16() {
2462        let mut arr = NDArray::new(
2463            vec![NDDimension::new(80), NDDimension::new(40)],
2464            NDDataType::UInt16,
2465        );
2466        if let NDDataBuffer::U16(ref mut v) = arr.data {
2467            for (i, x) in v.iter_mut().enumerate() {
2468                *x = (i * 37 % 65521) as u16;
2469            }
2470        }
2471        let original = arr.data.as_u8_slice().to_vec();
2472
2473        let compressed = compress_lz4hdf5(&arr);
2474        assert_eq!(
2475            compressed.codec.as_ref().unwrap().original_data_type,
2476            NDDataType::UInt16
2477        );
2478
2479        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
2480        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
2481        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2482    }
2483
2484    #[test]
2485    fn test_lz4hdf5_roundtrip_f64_with_negatives() {
2486        let mut arr = NDArray::new(vec![NDDimension::new(97)], NDDataType::Float64);
2487        if let NDDataBuffer::F64(ref mut v) = arr.data {
2488            for (i, x) in v.iter_mut().enumerate() {
2489                *x = (i as f64 - 48.0) * 1.75;
2490            }
2491        }
2492        let original = arr.data.as_u8_slice().to_vec();
2493
2494        let compressed = compress_lz4hdf5(&arr);
2495        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
2496        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
2497        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2498    }
2499
2500    #[test]
2501    fn test_lz4hdf5_multi_block_roundtrip() {
2502        // A buffer larger than the default block size exercises the per-block
2503        // container framing and a trailing partial block.
2504        let block = LZ4HDF5_DEFAULT_BLOCK_SIZE;
2505        let count = block * 2 + block / 3 + 7; // 2.33 blocks of u8.
2506        let mut arr = NDArray::new(vec![NDDimension::new(count)], NDDataType::UInt8);
2507        if let NDDataBuffer::U8(ref mut v) = arr.data {
2508            for (i, x) in v.iter_mut().enumerate() {
2509                *x = (i.wrapping_mul(2_654_435_761) % 251) as u8;
2510            }
2511        }
2512        let original = arr.data.as_u8_slice().to_vec();
2513
2514        let compressed = compress_lz4hdf5(&arr);
2515        let decompressed = decompress_lz4hdf5(&compressed).unwrap();
2516        assert_eq!(decompressed.data.as_u8_slice(), original.as_slice());
2517    }
2518
2519    #[test]
2520    fn test_lz4hdf5_compresses_repetitive_data() {
2521        let arr = NDArray::new(
2522            vec![NDDimension::new(256), NDDimension::new(256)],
2523            NDDataType::UInt16,
2524        );
2525        let original_size = arr.data.as_u8_slice().len();
2526        let compressed = compress_lz4hdf5(&arr);
2527        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
2528        assert!(
2529            compressed_size < original_size,
2530            "lz4hdf5 compressed ({compressed_size}) should be < original ({original_size})"
2531        );
2532    }
2533
2534    #[test]
2535    fn test_lz4hdf5_via_processor() {
2536        let mut arr = NDArray::new(
2537            vec![NDDimension::new(48), NDDimension::new(48)],
2538            NDDataType::UInt16,
2539        );
2540        if let NDDataBuffer::U16(ref mut v) = arr.data {
2541            for (i, x) in v.iter_mut().enumerate() {
2542                *x = (i * 7) as u16;
2543            }
2544        }
2545        let original = arr.data.as_u8_slice().to_vec();
2546        let pool = NDArrayPool::new(10_000_000);
2547
2548        let comp = CodecProcessor::new(CodecMode::Compress {
2549            codec: CodecName::LZ4HDF5,
2550            quality: 0,
2551        });
2552        let compressed = comp.process_array(&arr, &pool);
2553        let compressed_arr = &compressed.output_arrays[0];
2554        assert_eq!(
2555            compressed_arr.codec.as_ref().unwrap().name,
2556            CodecName::LZ4HDF5
2557        );
2558
2559        let decomp = CodecProcessor::new(CodecMode::Decompress);
2560        let result = decomp.process_array(compressed_arr, &pool);
2561        assert_eq!(
2562            result.output_arrays[0].data.as_u8_slice(),
2563            original.as_slice()
2564        );
2565    }
2566
2567    // ---- COMPRESSOR ordinal mapping ----
2568
2569    #[test]
2570    fn test_compressor_ordinal_mapping() {
2571        // C `NDCodecCompressor_t` (Codec.h:12-18): 0=None, 1=JPEG, 2=Blosc,
2572        // 3=LZ4, 4=BSLZ4. Rust-only zlib/lz4hdf5 follow at 5/6. Selecting a
2573        // compressor by its C ordinal must pick the matching `CodecName`.
2574        use ad_core_rs::plugin::runtime::{ParamChangeValue, PluginParamSnapshot};
2575
2576        let cases = [
2577            (0i32, CodecName::None),
2578            (1, CodecName::JPEG),
2579            (2, CodecName::Blosc),
2580            (3, CodecName::LZ4),
2581            (4, CodecName::BSLZ4),
2582            (5, CodecName::Zlib),
2583            (6, CodecName::LZ4HDF5),
2584        ];
2585
2586        for (ordinal, expected) in cases {
2587            let mut proc = CodecProcessor::new(CodecMode::Compress {
2588                codec: CodecName::LZ4,
2589                quality: 85,
2590            });
2591            // The compressor param index is otherwise discovered via
2592            // `register_params`; set it directly for the unit test.
2593            proc.params.compressor = Some(0);
2594            let snapshot = PluginParamSnapshot {
2595                enable_callbacks: true,
2596                reason: 0,
2597                addr: 0,
2598                value: ParamChangeValue::Int32(ordinal),
2599            };
2600            proc.on_param_change(0, &snapshot);
2601            match proc.state.lock().mode {
2602                CodecMode::Compress { codec, .. } => assert_eq!(
2603                    codec, expected,
2604                    "ordinal {ordinal} should select {expected:?}"
2605                ),
2606                other => panic!("expected Compress mode, got {other:?}"),
2607            }
2608        }
2609    }
2610
2611    // ---- Decompress wrong codec ----
2612
2613    #[test]
2614    fn test_decompress_wrong_codec() {
2615        let arr = make_u8_array(4, 4);
2616        assert!(decompress_lz4(&arr).is_none());
2617        assert!(decompress_jpeg(&arr).is_none());
2618        assert!(decompress_zlib(&arr).is_none());
2619        assert!(decompress_lz4hdf5(&arr).is_none());
2620    }
2621
2622    // ---- CodecProcessor tests ----
2623
2624    #[test]
2625    fn test_processor_lz4_compress() {
2626        let pool = NDArrayPool::new(1_000_000);
2627        let proc = CodecProcessor::new(CodecMode::Compress {
2628            codec: CodecName::LZ4,
2629            quality: 0,
2630        });
2631        let arr = make_u8_array(32, 32);
2632        let result = proc.process_array(&arr, &pool);
2633        assert_eq!(result.output_arrays.len(), 1);
2634        assert_eq!(
2635            result.output_arrays[0].codec.as_ref().unwrap().name,
2636            CodecName::LZ4
2637        );
2638        assert!(proc.compression_ratio() >= 1.0);
2639    }
2640
2641    #[test]
2642    fn test_processor_jpeg_compress() {
2643        let pool = NDArrayPool::new(1_000_000);
2644        let proc = CodecProcessor::new(CodecMode::Compress {
2645            codec: CodecName::JPEG,
2646            quality: 80,
2647        });
2648        let arr = make_u8_array(16, 16);
2649        let result = proc.process_array(&arr, &pool);
2650        assert_eq!(result.output_arrays.len(), 1);
2651        assert_eq!(
2652            result.output_arrays[0].codec.as_ref().unwrap().name,
2653            CodecName::JPEG
2654        );
2655    }
2656
2657    #[test]
2658    fn test_processor_decompress_auto_lz4() {
2659        let pool = NDArrayPool::new(1_000_000);
2660        let arr = make_u8_array(16, 16);
2661        let compressed = compress_lz4(&arr);
2662
2663        let proc = CodecProcessor::new(CodecMode::Decompress);
2664        let result = proc.process_array(&compressed, &pool);
2665        assert_eq!(result.output_arrays.len(), 1);
2666        assert!(result.output_arrays[0].codec.is_none());
2667        assert_eq!(
2668            result.output_arrays[0].data.as_u8_slice(),
2669            arr.data.as_u8_slice()
2670        );
2671        assert!(proc.compression_ratio() > 0.0);
2672    }
2673
2674    #[test]
2675    fn test_processor_decompress_auto_jpeg() {
2676        let pool = NDArrayPool::new(1_000_000);
2677        let arr = make_u8_array(16, 16);
2678        let compressed = compress_jpeg(&arr, 90).unwrap();
2679
2680        let proc = CodecProcessor::new(CodecMode::Decompress);
2681        let result = proc.process_array(&compressed, &pool);
2682        assert_eq!(result.output_arrays.len(), 1);
2683        assert!(result.output_arrays[0].codec.is_none());
2684    }
2685
2686    #[test]
2687    fn test_processor_decompress_no_codec() {
2688        let pool = NDArrayPool::new(1_000_000);
2689        let arr = make_u8_array(8, 8);
2690        let proc = CodecProcessor::new(CodecMode::Decompress);
2691        let result = proc.process_array(&arr, &pool);
2692        // C++: on failure, pass through original array unchanged
2693        assert_eq!(result.output_arrays.len(), 1);
2694        assert_eq!(proc.compression_ratio(), 1.0);
2695    }
2696
2697    // ---- R8-61: pass-through vs failure on the Codec plugin's exits ----
2698
2699    /// Param indices used by the R8-61 tests; `register_params` normally
2700    /// discovers them from the port, which a unit test has no need to build.
2701    fn processor_with_params(mode: CodecMode) -> CodecProcessor {
2702        let mut proc = CodecProcessor::new(mode);
2703        proc.params.comp_factor = Some(10);
2704        proc.params.compressor = Some(11);
2705        proc.params.codec_status = Some(12);
2706        proc.params.codec_error = Some(13);
2707        proc
2708    }
2709
2710    fn int32_update(updates: &[ParamUpdate], reason: usize) -> Option<i32> {
2711        updates.iter().find_map(|u| match u {
2712            ParamUpdate::Int32 {
2713                reason: r, value, ..
2714            } if *r == reason => Some(*value),
2715            _ => None,
2716        })
2717    }
2718
2719    fn octet_update(updates: &[ParamUpdate], reason: usize) -> Option<String> {
2720        updates.iter().find_map(|u| match u {
2721            ParamUpdate::Octet {
2722                reason: r, value, ..
2723            } if *r == reason => Some(value.clone()),
2724            _ => None,
2725        })
2726    }
2727
2728    #[test]
2729    fn test_r8_61_decompress_uncompressed_input_is_success_passthrough() {
2730        // C NDPluginCodec.cpp:732-735 — Decompress mode on an array with an empty
2731        // codec: result = pArray, COMPRESSOR = NDCODEC_NONE, codecStatus stays
2732        // SUCCESS and no error string is set. The port reported CodecStatus=1 +
2733        // "codec operation failed or unsupported" and never wrote COMPRESSOR.
2734        let pool = NDArrayPool::new(1_000_000);
2735        let arr = make_u8_array(8, 8);
2736        let proc = processor_with_params(CodecMode::Decompress);
2737        let result = proc.process_array(&arr, &pool);
2738
2739        assert_eq!(
2740            int32_update(&result.param_updates, 12),
2741            Some(0),
2742            "CodecStatus must stay SUCCESS on an uncompressed input"
2743        );
2744        assert_eq!(
2745            octet_update(&result.param_updates, 13),
2746            Some(String::new()),
2747            "no error string on a pass-through"
2748        );
2749        assert_eq!(
2750            int32_update(&result.param_updates, 11),
2751            Some(0),
2752            "COMPRESSOR must be set to NDCODEC_NONE"
2753        );
2754        assert_eq!(
2755            result.output_arrays[0].data.as_u8_slice(),
2756            arr.data.as_u8_slice(),
2757            "the input array is passed through unchanged"
2758        );
2759        assert_eq!(proc.compression_ratio(), 1.0);
2760    }
2761
2762    #[test]
2763    fn test_r8_61_decompress_reports_compressor_of_the_input_codec() {
2764        // C sets NDCodecCompressor on every decompress branch (:739/:747/:752/
2765        // :757) from the codec found on the input; the port never wrote it.
2766        let pool = NDArrayPool::new(1_000_000);
2767        let src = make_u8_array(16, 16);
2768        for (codec, ordinal) in [
2769            (compress_lz4(&src), 3),
2770            (compress_blosc(&src, &BloscConfig::default()), 2),
2771            (compress_bslz4(&src), 4),
2772            (compress_jpeg(&src, 90).expect("jpeg"), 1),
2773        ] {
2774            let proc = processor_with_params(CodecMode::Decompress);
2775            let result = proc.process_array(&codec, &pool);
2776            assert_eq!(
2777                int32_update(&result.param_updates, 11),
2778                Some(ordinal),
2779                "COMPRESSOR must report the input codec's C ordinal"
2780            );
2781            assert_eq!(
2782                int32_update(&result.param_updates, 12),
2783                Some(0),
2784                "a successful decompress is SUCCESS"
2785            );
2786        }
2787    }
2788
2789    #[test]
2790    fn test_r8_61_compress_with_compressor_none_is_success_passthrough() {
2791        // C :671 gates the already-compressed check on `algo`, and :680-683 maps
2792        // `case NDCODEC_NONE: default:` to `result = pArray` — a COMPRESSOR=None
2793        // compress plugin is a SUCCESS pass-through, not a codec failure. The
2794        // port's catch-all `Compress { .. } => None` sent it to the error branch.
2795        let pool = NDArrayPool::new(1_000_000);
2796        let arr = make_u8_array(8, 8);
2797        let proc = processor_with_params(CodecMode::Compress {
2798            codec: CodecName::None,
2799            quality: 85,
2800        });
2801        let result = proc.process_array(&arr, &pool);
2802
2803        assert_eq!(int32_update(&result.param_updates, 12), Some(0));
2804        assert_eq!(octet_update(&result.param_updates, 13), Some(String::new()));
2805        assert_eq!(
2806            int32_update(&result.param_updates, 11),
2807            None,
2808            "compress mode must not overwrite the operator's COMPRESSOR selection"
2809        );
2810        assert!(result.output_arrays[0].codec.is_none());
2811        assert_eq!(
2812            result.output_arrays[0].data.as_u8_slice(),
2813            arr.data.as_u8_slice()
2814        );
2815    }
2816
2817    #[test]
2818    fn test_r8_61_genuine_decompress_failure_still_reports_an_error() {
2819        // The pass-through paths must not swallow real failures: a truncated LZ4
2820        // payload still reports a non-zero CodecStatus + an error string, and
2821        // still republishes the input (C `finish:` block, :770-776).
2822        let pool = NDArrayPool::new(1_000_000);
2823        let arr = make_u8_array(16, 16);
2824        let mut corrupted = compress_lz4(&arr);
2825        if let NDDataBuffer::U8(ref mut v) = corrupted.data {
2826            v.truncate(3);
2827        }
2828        let proc = processor_with_params(CodecMode::Decompress);
2829        let result = proc.process_array(&corrupted, &pool);
2830
2831        assert_ne!(
2832            int32_update(&result.param_updates, 12),
2833            Some(0),
2834            "a failed decompress must not report SUCCESS"
2835        );
2836        assert_eq!(
2837            octet_update(&result.param_updates, 13),
2838            Some("Failed to LZ4 decompress".to_string())
2839        );
2840        assert_eq!(int32_update(&result.param_updates, 11), Some(3));
2841        assert_eq!(
2842            result.output_arrays[0].data.as_u8_slice(),
2843            corrupted.data.as_u8_slice(),
2844            "the input array is republished on failure"
2845        );
2846    }
2847
2848    // ---- R8-63: the three-level CodecStatus contract ----
2849
2850    #[test]
2851    fn test_r8_63_status_levels_match_c() {
2852        // C NDCodecStatus_t (NDPluginCodec.h:42-46): SUCCESS=0, WARNING=1,
2853        // ERROR=2. These are the values every CodecStatus PV client reads.
2854        assert_eq!(CodecStatus::Success.as_i32(), 0);
2855        assert_eq!(CodecStatus::Warning.as_i32(), 1);
2856        assert_eq!(CodecStatus::Error.as_i32(), 2);
2857    }
2858
2859    #[test]
2860    fn test_r8_63_already_compressed_is_a_warning_not_success() {
2861        // C `NDPluginCodec.cpp:671-676` — compressing an already-compressed array is benign
2862        // but not silent: errorMessage "Array already compressed", codecStatus WARNING, and
2863        // the input passes through. The port reported SUCCESS with no error.
2864        let pool = NDArrayPool::new(1_000_000);
2865        let compressed = compress_lz4(&make_u8_array(16, 16));
2866        let proc = processor_with_params(CodecMode::Compress {
2867            codec: CodecName::Zlib,
2868            quality: 85,
2869        });
2870        let result = proc.process_array(&compressed, &pool);
2871
2872        assert_eq!(
2873            int32_update(&result.param_updates, 12),
2874            Some(CodecStatus::Warning.as_i32()),
2875            "already-compressed input must report WARNING(1)"
2876        );
2877        assert_eq!(
2878            octet_update(&result.param_updates, 13),
2879            Some("Array already compressed".to_string())
2880        );
2881        // The frame still flows on, still LZ4-compressed.
2882        assert_eq!(
2883            result.output_arrays[0].codec.as_ref().unwrap().name,
2884            CodecName::LZ4
2885        );
2886    }
2887
2888    #[test]
2889    fn test_r8_63_genuine_failures_are_error_not_warning() {
2890        // C `NDPluginCodec.cpp` reports ERROR(2) for real failures: a JPEG-unsupported input
2891        // (`:141`/`:167`/`:202`/`:252`) and a codec that fails to decode (`:279`, `:760`).
2892        // The port hardcoded 1 (WARNING) on every failure, making the two levels
2893        // indistinguishable.
2894        let pool = NDArrayPool::new(1_000_000);
2895
2896        // Compress: UInt16 is not JPEG-encodable ("JPEG only supports 8-bit data").
2897        let wide = NDArray::new(
2898            vec![NDDimension::new(8), NDDimension::new(8)],
2899            NDDataType::UInt16,
2900        );
2901        let proc = processor_with_params(CodecMode::Compress {
2902            codec: CodecName::JPEG,
2903            quality: 85,
2904        });
2905        let result = proc.process_array(&wide, &pool);
2906        assert_eq!(
2907            int32_update(&result.param_updates, 12),
2908            Some(CodecStatus::Error.as_i32()),
2909            "an unsupported JPEG input is an ERROR"
2910        );
2911
2912        // Decompress: a truncated payload is a decoder failure.
2913        let mut corrupted = compress_lz4(&make_u8_array(16, 16));
2914        if let NDDataBuffer::U8(ref mut v) = corrupted.data {
2915            v.truncate(3);
2916        }
2917        let proc = processor_with_params(CodecMode::Decompress);
2918        let result = proc.process_array(&corrupted, &pool);
2919        assert_eq!(
2920            int32_update(&result.param_updates, 12),
2921            Some(CodecStatus::Error.as_i32()),
2922            "a failed decompress is an ERROR"
2923        );
2924    }
2925
2926    #[test]
2927    fn test_r8_63_successful_and_passthrough_paths_report_success() {
2928        // The other two levels must stay at SUCCESS(0): a real compression, and the
2929        // pass-through exits (C `NDPluginCodec.cpp:659`, `:680-683`, `:732-735`).
2930        let pool = NDArrayPool::new(1_000_000);
2931        let arr = make_u8_array(16, 16);
2932
2933        let proc = processor_with_params(CodecMode::Compress {
2934            codec: CodecName::LZ4,
2935            quality: 85,
2936        });
2937        let compressed = proc.process_array(&arr, &pool);
2938        assert_eq!(
2939            int32_update(&compressed.param_updates, 12),
2940            Some(CodecStatus::Success.as_i32())
2941        );
2942
2943        let proc = processor_with_params(CodecMode::Decompress);
2944        let passthrough = proc.process_array(&arr, &pool);
2945        assert_eq!(
2946            int32_update(&passthrough.param_updates, 12),
2947            Some(CodecStatus::Success.as_i32())
2948        );
2949    }
2950
2951    #[test]
2952    fn test_processor_compression_ratio() {
2953        let pool = NDArrayPool::new(1_000_000);
2954        // Create highly compressible data (all zeros)
2955        let mut arr = NDArray::new(
2956            vec![NDDimension::new(128), NDDimension::new(128)],
2957            NDDataType::UInt8,
2958        );
2959        if let NDDataBuffer::U8(ref mut v) = arr.data {
2960            for x in v.iter_mut() {
2961                *x = 0;
2962            }
2963        }
2964
2965        let proc = CodecProcessor::new(CodecMode::Compress {
2966            codec: CodecName::LZ4,
2967            quality: 0,
2968        });
2969        let _ = proc.process_array(&arr, &pool);
2970        let ratio = proc.compression_ratio();
2971        assert!(
2972            ratio > 2.0,
2973            "all-zeros 128x128 should compress at least 2x, got {}",
2974            ratio,
2975        );
2976    }
2977
2978    #[test]
2979    fn test_processor_plugin_type() {
2980        let proc = CodecProcessor::new(CodecMode::Decompress);
2981        assert_eq!(proc.plugin_type(), "NDPluginCodec");
2982    }
2983
2984    // ---- buffer_from_bytes tests ----
2985
2986    #[test]
2987    fn test_buffer_from_bytes_u8() {
2988        let data = vec![1u8, 2, 3, 4];
2989        let buf = buffer_from_bytes(&data, NDDataType::UInt8).unwrap();
2990        assert_eq!(buf.data_type(), NDDataType::UInt8);
2991        assert_eq!(buf.len(), 4);
2992        assert_eq!(buf.as_u8_slice(), &[1, 2, 3, 4]);
2993    }
2994
2995    #[test]
2996    fn test_buffer_from_bytes_u16() {
2997        let original = vec![1000u16, 2000, 3000];
2998        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
2999        let buf = buffer_from_bytes(&bytes, NDDataType::UInt16).unwrap();
3000        assert_eq!(buf.data_type(), NDDataType::UInt16);
3001        assert_eq!(buf.len(), 3);
3002        if let NDDataBuffer::U16(v) = buf {
3003            assert_eq!(v, original);
3004        } else {
3005            panic!("wrong buffer type");
3006        }
3007    }
3008
3009    #[test]
3010    fn test_buffer_from_bytes_bad_alignment() {
3011        // 3 bytes can't form a u16 array
3012        let data = vec![0u8; 3];
3013        assert!(buffer_from_bytes(&data, NDDataType::UInt16).is_none());
3014    }
3015
3016    #[test]
3017    fn test_buffer_from_bytes_f64_roundtrip() {
3018        let original = vec![1.5f64, -2.7, 3.14159];
3019        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
3020        let buf = buffer_from_bytes(&bytes, NDDataType::Float64).unwrap();
3021        if let NDDataBuffer::F64(v) = buf {
3022            assert_eq!(v, original);
3023        } else {
3024            panic!("wrong buffer type");
3025        }
3026    }
3027}